{
 "openapi": "3.1.0",
 "info": {
  "title": "Zonitix instance API: hand-checked route catalogue",
  "version": "99e5035a2",
  "description": "Built from the route catalogue printed on the Zonitix documentation site, as read at app revision 99e5035a2. It is not the file an instance serves at /api/docs/openapi.json: that one is a saved file built from the product's route definitions and shipped with the release the instance runs; it covers fewer routes and carries response schemas for some.\n\nThis file covers more routes and types fewer of them. Each operation carries the catalogue's own one-line text for the request and the reply in its description. Where the catalogue gives no field types the schema is the empty schema {} (any JSON): it is a placeholder, not a statement that anything is accepted. Typed objects are under components.schemas. Where a request or reply line opens with a typed object, an array of one, or an object written as { key:type, ... }, the schema says so: a key is described when the line gives it a typed object or a plain type, and x-keys-not-described lists the keys the line names with no type. An operation also names every typed object its text mentions in x-typed-objects, which does not say where in the reply they sit. Where two modules print an object under one name, both are listed and neither is referenced. x-catalogue.schemaCoverage counts the operations with a described request body and a described reply, and how many of those point at a typed object.\n\nEvery path parameter is declared with a type. x-type-basis says where the type comes from: 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, and its description says UNKNOWN. Query parameters are declared only for the operations whose handler was read for them (x-catalogue.schemaCoverage.operationsWithQueryParameters); on every other operation they are described in the operation text only, so an operation with no query parameter here may still read some. Where a query parameter has a minimum or a maximum, a value outside it is moved to the nearest bound, not refused. A device-signed operation declares its four signing headers.\n\nIt is a subset: a route that is not here may still exist. A request body is marked application/json when the catalogue writes it as a JSON object and */* otherwise. x-permissions-named lists the permission names in the auth text; x-auth says whether any one or all are needed. A session cookie is the only credential stated for every \"Session\" operation; many also accept a personal API token (Authorization: Bearer zx_...), but an operation with x-step-up: required or x-administrator-role does not; with x-step-up: conditional, only the calls its text names need a step-up. An operation is unauthenticated only where security is an empty list. Where the credential is neither a session nor a SCIM bearer token, the operation carries the namedInXAuth scheme and x-auth names the credential in the catalogue's words."
 },
 "servers": [
  {
   "url": "https://{apiHost}/api/v1",
   "variables": {
    "apiHost": {
     "default": "your-instance-api.zonitix.com",
     "description": "Your instance's API address: your instance name followed by -api."
    }
   }
  }
 ],
 "tags": [
  {
   "name": "Session & Authentication",
   "description": "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."
  },
  {
   "name": "MFA, Passkeys & Trusted Devices",
   "description": "TOTP, WebAuthn/passkeys, recovery codes, and the trusted-device list. Admin reset actions require sudo."
  },
  {
   "name": "Security Settings, Access Control & Audit Sessions",
   "description": "Instance-wide security configuration, geo/IP access rules, and the audit-session bookkeeping used by the Compliance Hub."
  },
  {
   "name": "Users, Roles, Groups & Permissions",
   "description": "The RBAC core, the org chart, and per-user data export. All paths are prefixed /user-management."
  },
  {
   "name": "Workflows & Tasks",
   "description": "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."
  },
  {
   "name": "Identity Hooks & Alignment",
   "description": "Connected identity/SaaS providers, drift detection against a source of truth, and governed write-back."
  },
  {
   "name": "Compliance, Reporting & System Health",
   "description": "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."
  },
  {
   "name": "Security Lab",
   "description": "Automated probes and scans. Reached through the gateway under /auth/security-lab. Launching requires the Security Lab entitlement on your license."
  },
  {
   "name": "Cloud Discovery (AWS / Azure / GCP)",
   "description": "Read-only cloud inventory, topology, identity-fusion findings, and governed remediation. All require the cloud-mapping entitlement; remediation additionally requires sudo."
  },
  {
   "name": "Recruitment & Hiring",
   "description": "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."
  },
  {
   "name": "Recruitment: Candidates & Pipeline",
   "description": "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."
  },
  {
   "name": "Recruitment: Interviews & Scorecards",
   "description": "Scheduling, candidate self-scheduling proposals, scorecards and the interview templates behind them."
  },
  {
   "name": "Recruitment: Offers & Signing",
   "description": "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."
  },
  {
   "name": "Recruitment: Assistant, Referrals & Reports",
   "description": "The recruitment assistant, employee referrals, pipeline analytics, the hiring report and the careers-page settings."
  },
  {
   "name": "Recruitment: Careers Site, Candidate Portal & Webhooks",
   "description": "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."
  },
  {
   "name": "Finance & Document Parser",
   "description": "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."
  },
  {
   "name": "Finance: Insights, Vendors & Settings",
   "description": "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."
  },
  {
   "name": "Finance: Purchasing, Approvals & Close",
   "description": "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."
  },
  {
   "name": "Finance: Budgets, Transfers & Spend Requests",
   "description": "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."
  },
  {
   "name": "Local AI",
   "description": "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."
  },
  {
   "name": "Audit Logs & Device Inventory",
   "description": "The audit log and the manual device catalogue, reached through the /user-management prefix. Fleet management routes are in the Device Management group."
  },
  {
   "name": "Zonitix Agent (Endpoints)",
   "description": "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."
  },
  {
   "name": "Device Management",
   "description": "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."
  },
  {
   "name": "Personal API Tokens",
   "description": "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."
  },
  {
   "name": "Single Sign-On (OIDC & SAML)",
   "description": "The browser sign-in redirects under /auth/sso carry no session and are rate-limited; the configuration routes under /identity-admin are administrative."
  },
  {
   "name": "SCIM 2.0 Provisioning",
   "description": "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."
  },
  {
   "name": "Access Reviews, Access Requests & Departments",
   "description": "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."
  },
  {
   "name": "User Import, Invites & Bulk Actions",
   "description": "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."
  },
  {
   "name": "Log Forwarding & Support Access",
   "description": "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."
  },
  {
   "name": "Report Engine & Scheduled Reports",
   "description": "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."
  },
  {
   "name": "Compliance Hub: Policies, Risks, Vendors & Auditors",
   "description": "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."
  },
  {
   "name": "Public Share Links",
   "description": "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."
  },
  {
   "name": "Backups, Restore & Release Channel",
   "description": "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."
  },
  {
   "name": "Company Data Export",
   "description": "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."
  },
  {
   "name": "Security Lab: Schedules, Issues, Exceptions & Attack Paths",
   "description": "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."
  },
  {
   "name": "Employee Files, Pay History, Documents & Payroll Hand-off",
   "description": "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."
  },
  {
   "name": "Time Off",
   "description": "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."
  },
  {
   "name": "Goals, Review Cycles & Surveys",
   "description": "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."
  },
  {
   "name": "Training: Courses, Learning Paths & Imports",
   "description": "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."
  },
  {
   "name": "Phishing Simulation",
   "description": "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."
  },
  {
   "name": "Policy Signing & People Evidence",
   "description": "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."
  }
 ],
 "paths": {
  "/auth/login": {
   "post": {
    "operationId": "post_auth_login",
    "tags": [
     "Session & Authentication"
    ],
    "summary": "Exchange credentials for a session; returns an MFA or terms-acceptance challenge when required.",
    "description": "Request, as the catalogue words it: A LoginRequest: { username, password } required (username<=255, password<=1024); optional consentAccepted:boolean to accept terms in-line\n\nReply, as the catalogue words it: 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')\n\nGuard: 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\n\nAuth: Public",
    "requestBody": {
     "description": "A LoginRequest: { username, password } required (username<=255, password<=1024); optional consentAccepted:boolean to accept terms in-line",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/LoginRequest"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "a SessionGrant { auth:true, token } | 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)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "401": {
      "description": "bad creds (gateway maps 401/403 to 'Invalid username or password')"
     }
    },
    "security": [],
    "x-auth": "Public",
    "x-permissions-named": [],
    "x-typed-objects": [
     "SessionGrant",
     "LoginTermsChallenge",
     "LoginPasswordChangeChallenge",
     "LoginMfaChallenge"
    ],
    "x-catalogue-group": "gw-auth"
   }
  },
  "/auth/logout": {
   "post": {
    "operationId": "post_auth_logout",
    "tags": [
     "Session & Authentication"
    ],
    "summary": "Invalidate the current session and clear the session cookie.",
    "description": "Request, as the catalogue words it: No body from client — gateway reads the session token from the __Host-token cookie and forwards { token } to identity\n\nReply, as the catalogue words it: 200 { ok:true } always\n\nGuard: 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\n\nAuth: Public",
    "responses": {
     "200": {
      "description": "{ ok:true } always",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-auth"
   }
  },
  "/auth/me": {
   "get": {
    "operationId": "get_auth_me",
    "tags": [
     "Session & Authentication"
    ],
    "summary": "Return the signed-in user with resolved roles, groups, and effective permissions.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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)\n\nGuard: read-only and safe to repeat; a 503 is not a sign-out — only a 401 means the session is gone\n\nAuth: Session",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "auth": {
           "type": "boolean"
          },
          "user": {
           "$ref": "#/components/schemas/CurrentUser"
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "{ auth:false } when the token is missing, invalid, blacklisted, or the account is blocked or its sessions were invalidated",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "503": {
      "description": "{ degraded:true, message } when the profile cannot be resolved right now (the session is still valid; retry)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-typed-objects": [
     "CurrentUser",
     "SupportSessionInfo"
    ],
    "x-catalogue-group": "gw-auth"
   }
  },
  "/auth/refresh": {
   "post": {
    "operationId": "post_auth_refresh",
    "tags": [
     "Session & Authentication"
    ],
    "summary": "Refresh the session token before expiry.",
    "description": "Request, as the catalogue words it: No body from client — gateway extracts token from the Authorization bearer/cookie and forwards { token }\n\nReply, as the catalogue words it: 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\n\nGuard: requires authenticateToken at gateway; refuses non-session token types (isMfaSession/isPasswordChangeSession/isMagicLink) so a single-use email credential can't mint a session\n\nAuth: Session",
    "responses": {
     "200": {
      "description": "a SessionGrant { auth:true, token } (new session, also set as the session cookie)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/SessionGrant"
        }
       }
      }
     },
     "400": {
      "description": "{ 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)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "401": {
      "description": "from the gateway's own session check (no token, or a session that is expired, revoked or no longer active)"
     },
     "403": {
      "description": "account not active"
     },
     "404": {
      "description": "user not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-typed-objects": [
     "SessionGrant"
    ],
    "x-catalogue-group": "gw-auth"
   }
  },
  "/auth/password/change": {
   "post": {
    "operationId": "post_auth_password_change",
    "tags": [
     "Session & Authentication"
    ],
    "summary": "Change your own password (enforces the configured password policy).",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 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\n\nGuard: anti-IDOR: account taken from verified token claims only; token single-use (blacklisted after); revokes ALL trusted devices as breach response; authLimiter rate-limited\n\nAuth: Session",
    "requestBody": {
     "description": "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",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/PasswordChangeRequest"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "a PasswordChangeResult { success:true, message, devicesRevoked:integer }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/PasswordChangeResult"
        }
       }
      }
     },
     "400": {
      "description": "policy fail or same-as-current"
     },
     "401": {
      "description": "missing/invalid/expired token"
     },
     "403": {
      "description": "token is not a password-change token"
     },
     "404": {
      "description": "user not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-typed-objects": [
     "PasswordChangeResult"
    ],
    "x-catalogue-group": "gw-auth"
   }
  },
  "/auth/sudo": {
   "post": {
    "operationId": "post_auth_sudo",
    "tags": [
     "Session & Authentication"
    ],
    "summary": "Step-up re-authentication that unlocks sudo-gated actions for a short window.",
    "description": "Request, as the catalogue words it: A StepUpRequest: { password } required; current session token in Authorization: Bearer header\n\nReply, as the catalogue words it: 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\n\nGuard: re-verifies the user's password (bcrypt) to step up; sudoLimiter rate-limited; blocked-status users refused; failures audit-logged\n\nAuth: Session",
    "requestBody": {
     "description": "A StepUpRequest: { password } required; current session token in Authorization: Bearer header",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/StepUpRequest"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "a SessionGrant { auth:true, token } (sudo-elevated token, also set as the session cookie)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/SessionGrant"
        }
       }
      }
     },
     "400": {
      "description": "{ 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)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "401": {
      "description": "only from the gateway's own session check, when there is no valid session at all"
     },
     "429": {
      "description": "when the step-up attempt budget is used up"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-typed-objects": [
     "SessionGrant"
    ],
    "x-catalogue-group": "gw-auth"
   }
  },
  "/auth/magic-link/request": {
   "post": {
    "operationId": "post_auth_magic_link_request",
    "tags": [
     "Session & Authentication"
    ],
    "summary": "Email a one-time sign-in link.",
    "description": "Request, as the catalogue words it: A SignInLinkRequest: { email } required\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Public",
    "requestBody": {
     "description": "A SignInLinkRequest: { email } required",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/SignInLinkRequest"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "{ error, correlationId }: 'email is required' when email is missing or empty, 'Enter your email address.' when it is only spaces",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "409": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-auth"
   }
  },
  "/auth/magic-link/verify": {
   "get": {
    "operationId": "get_auth_magic_link_verify",
    "tags": [
     "Session & Authentication"
    ],
    "summary": "Redeem a magic-link token. Opens a session, or hands back the next step when one is owed.",
    "description": "Request, as the catalogue words it: Query: ?token= required\n\nReply, as the catalogue words it: 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)\n\nGuard: 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.'\n\nAuth: Public",
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/SessionGrant"
        }
       }
      }
     },
     "400": {
      "description": "{ 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)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public",
    "x-permissions-named": [],
    "x-typed-objects": [
     "SessionGrant",
     "LoginTermsChallenge"
    ],
    "x-catalogue-group": "gw-auth"
   }
  },
  "/auth/accept-terms": {
   "post": {
    "operationId": "post_auth_accept_terms",
    "tags": [
     "Session & Authentication"
    ],
    "summary": "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).",
    "description": "Request, as the catalogue words it: { termsToken, consentAccepted:true } both required\n\nReply, as the catalogue words it: 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)\n\nGuard: single use: the terms token is claimed before anything is issued, so a replay is refused; counted by the sign-in rate limiter\n\nAuth: Public",
    "requestBody": {
     "description": "{ termsToken, consentAccepted:true } both required",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "consentAccepted": {
          "type": "boolean"
         }
        },
        "x-keys-not-described": [
         "termsToken"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/SessionGrant"
        }
       }
      }
     },
     "400": {
      "description": "'Missing token.' | 'You must accept the Terms of Service and Privacy Policy to continue.' when consentAccepted is not true | { 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)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public",
    "x-permissions-named": [],
    "x-typed-objects": [
     "SessionGrant"
    ],
    "x-catalogue-group": "gw-auth"
   }
  },
  "/auth/setup/status": {
   "get": {
    "operationId": "get_auth_setup_status",
    "tags": [
     "Session & Authentication"
    ],
    "summary": "Report whether first-run setup is still pending, and whether a one-time setup key is required (setupKeyRequired). Used by the setup wizard.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nAuth: Public",
    "responses": {
     "200": {
      "description": "{ initialSetupCompleted, setupKeyRequired, setupKeyNotConfiguredMessage (string or null), branding:{ companyName, logoUrl, primaryBrandColor } }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "branding": {
           "type": "object",
           "x-keys-not-described": [
            "companyName",
            "logoUrl",
            "primaryBrandColor"
           ]
          }
         },
         "x-keys-not-described": [
          "initialSetupCompleted",
          "setupKeyRequired",
          "setupKeyNotConfiguredMessage"
         ]
        }
       }
      }
     },
     "503": {
      "description": "{ error, correlationId } when setup state cannot be read (never reported as 'not set up'); retry",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-auth"
   }
  },
  "/auth/setup/unlock": {
   "post": {
    "operationId": "post_auth_setup_unlock",
    "tags": [
     "Session & Authentication"
    ],
    "summary": "Check the one-time setup key before the wizard shows its steps. It completes nothing; the key is checked again on complete.",
    "description": "Request, as the catalogue words it: { setupKey } required\n\nReply, as the catalogue words it: 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)\n\nGuard: 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\n\nAuth: Public",
    "requestBody": {
     "description": "{ setupKey } required",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "invalid setup key"
     },
     "409": {
      "description": "code SETUP_KEY_NOT_CONFIGURED (no setup key is configured on the installation)"
     },
     "410": {
      "description": "setup key expired"
     },
     "429": {
      "description": "too many attempts"
     }
    },
    "security": [],
    "x-auth": "Public",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-auth"
   }
  },
  "/auth/setup/complete": {
   "post": {
    "operationId": "post_auth_setup_complete",
    "tags": [
     "Session & Authentication"
    ],
    "summary": "Create the first owner admin and initial security settings. First run only.",
    "description": "Request, as the catalogue words it: { 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)\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Public",
    "requestBody": {
     "description": "{ 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)",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "adminInfo": {
          "type": "object",
          "x-keys-not-described": [
           "email",
           "password"
          ]
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "missing email/password, password below bootstrap policy, or an invalid updates value"
     },
     "401": {
      "description": "invalid setup key"
     },
     "403": {
      "description": "setup already completed / a primary admin already exists"
     },
     "409": {
      "description": "code SETUP_KEY_NOT_CONFIGURED | setup already in progress"
     },
     "410": {
      "description": "setup key expired"
     },
     "429": {
      "description": "too many setup-key attempts"
     },
     "503": {
      "description": "cannot verify setup state"
     }
    },
    "security": [],
    "x-auth": "Public",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-auth"
   }
  },
  "/auth/settings/branding": {
   "get": {
    "operationId": "get_auth_settings_branding",
    "tags": [
     "Session & Authentication"
    ],
    "summary": "Public branding (name, logo, colours) for the login screen.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nGuard: a body carrying degraded:true is a fallback, not the saved settings — do not write it back\n\nAuth: Public",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-auth"
   },
   "post": {
    "operationId": "post_auth_settings_branding",
    "tags": [
     "Security Settings, Access Control & Audit Sessions"
    ],
    "summary": "Save company branding and the instance's regional defaults (the Branding page).",
    "description": "Request, as the catalogue words it: { 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\n\nReply, as the catalogue words it: 200 { success:true } | 400 'No branding fields supplied.' | 400 unknown time zone or locale, naming the value\n\nGuard: audited as BRANDING_UPDATED and, when a regional default changed, REGIONAL_SETTINGS_UPDATED\n\nAuth: Session + manage_security",
    "requestBody": {
     "description": "{ 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",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "branding": {
          "type": "object",
          "x-keys-not-described": [
           "companyName",
           "logoUrl",
           "primaryBrandColor"
          ]
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "'No branding fields supplied.' | unknown time zone or locale, naming the value"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-catalogue-group": "gw-system"
   }
  },
  "/auth/mfa/setup/init": {
   "post": {
    "operationId": "post_auth_mfa_setup_init",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Begin TOTP enrolment; returns the secret and provisioning URI.",
    "description": "Request, as the catalogue words it: An MfaSetupStart: { mfaToken } optional — MFA-challenge or full session token; else read from Authorization Bearer header. No other fields.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session",
    "requestBody": {
     "description": "An MfaSetupStart: { mfaToken } optional — MFA-challenge or full session token; else read from Authorization Bearer header. No other fields.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/MfaSetupStart"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "a TotpSetup { secret, otpauth_url } (typed under Response Objects); stages a PENDING TOTP secret",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/TotpSetup"
        }
       }
      }
     },
     "400": {
      "description": "{ 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)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "wrong session type, or enrolment not allowed for this session"
     },
     "404": {
      "description": "user not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-typed-objects": [
     "TotpSetup"
    ],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/mfa/setup/confirm": {
   "post": {
    "operationId": "post_auth_mfa_setup_confirm",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Confirm TOTP enrolment with a first code and issue recovery codes.",
    "description": "Request, as the catalogue words it: An MfaSetupConfirm: { token } required (6-digit TOTP code); { mfaToken } optional (else Authorization Bearer).\n\nReply, as the catalogue words it: 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\n\nGuard: enrollment token type + denyUnlessMayEnroll; only a proven code promotes pendingSecret to live mfaSecret\n\nAuth: Session",
    "requestBody": {
     "description": "An MfaSetupConfirm: { token } required (6-digit TOTP code); { mfaToken } optional (else Authorization Bearer).",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/MfaSetupConfirm"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "an MfaEnrolmentResult { auth:true, token, recoveryCodes } (recoveryCodes is null when the account already had codes)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/MfaEnrolmentResult"
        }
       }
      }
     },
     "400": {
      "description": "{ 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)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "wrong session type"
     },
     "404": {
      "description": "user not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-typed-objects": [
     "MfaEnrolmentResult"
    ],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/mfa/verify": {
   "post": {
    "operationId": "post_auth_mfa_verify",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Verify a TOTP code to complete a login challenge.",
    "description": "Request, as the catalogue words it: An MfaVerifyRequest: { mfaToken, token } required — mfaToken is the login-challenge JWT, token is the TOTP code; { rememberDevice } optional bool.\n\nReply, as the catalogue words it: 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\n\nGuard: mfaToken.isMfaSession must be true; TOTP verified against user.mfaSecret window 1\n\nAuth: Public (login challenge)",
    "requestBody": {
     "description": "An MfaVerifyRequest: { mfaToken, token } required — mfaToken is the login-challenge JWT, token is the TOTP code; { rememberDevice } optional bool.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/MfaVerifyRequest"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "an MfaVerifyResult { auth:true, token, trustedDeviceToken } (trustedDeviceToken is null unless rememberDevice was true)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/MfaVerifyResult"
        }
       }
      }
     },
     "400": {
      "description": "code NO_TOTP_ENROLLED | { 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)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "not an MFA-session token"
     },
     "404": {
      "description": "user not found"
     },
     "423": {
      "description": "code MFA_LOCKED when repeated wrong codes locked the account for 15 minutes"
     }
    },
    "security": [],
    "x-auth": "Public (login challenge)",
    "x-permissions-named": [],
    "x-typed-objects": [
     "MfaVerifyResult"
    ],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/mfa/verify-recovery": {
   "post": {
    "operationId": "post_auth_mfa_verify_recovery",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Complete a login challenge using a one-time recovery code.",
    "description": "Request, as the catalogue words it: An MfaRecoveryVerify: { mfaToken, recoveryCode } required (recoveryCode a string); { rememberDevice } optional bool.\n\nReply, as the catalogue words it: 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\n\nGuard: mfaToken.isMfaSession required; one-time — matched code marked used, bcrypt-compared against stored hashes\n\nAuth: Public (login challenge)",
    "requestBody": {
     "description": "An MfaRecoveryVerify: { mfaToken, recoveryCode } required (recoveryCode a string); { rememberDevice } optional bool.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/MfaRecoveryVerify"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "an MfaVerifyResult { auth:true, token, trustedDeviceToken } (trustedDeviceToken is null unless rememberDevice was true)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/MfaVerifyResult"
        }
       }
      }
     },
     "400": {
      "description": "'Recovery code is required' / 'No recovery codes available for this user' | { 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)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "not an MFA-session token"
     },
     "404": {
      "description": "user not found"
     },
     "423": {
      "description": "code MFA_LOCKED when repeated wrong codes locked the account for 15 minutes"
     }
    },
    "security": [],
    "x-auth": "Public (login challenge)",
    "x-permissions-named": [],
    "x-typed-objects": [
     "MfaVerifyResult"
    ],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/mfa/recovery-codes/regenerate": {
   "post": {
    "operationId": "post_auth_mfa_recovery_codes_regenerate",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Invalidate and reissue your recovery codes.",
    "description": "Request, as the catalogue words it: No body. Caller identified from Authorization Bearer (full session); gateway also forwards cookie token.\n\nReply, as the catalogue words it: 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\n\nGuard: must be a full session (not mfaSession/magicLink/pwChange) AND decoded.mfaVerified===true\n\nAuth: Session",
    "responses": {
     "200": {
      "description": "{ recoveryCodes } (new raw set, invalidates old)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "{ 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)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "wrong session type or code MFA_VERIFICATION_REQUIRED"
     },
     "404": {
      "description": "user not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/webauthn/register/generate": {
   "get": {
    "operationId": "get_auth_webauthn_register_generate",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Get a WebAuthn registration challenge for a new passkey.",
    "description": "Request, as the catalogue words it: Query: ?mfaToken= optional (the login-challenge token during enrolment); otherwise the session is read from the Authorization bearer or cookie\n\nReply, as the catalogue words it: 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\n\nGuard: each call stores a fresh one-shot challenge for the user and replaces the previous one, so only the latest options can be verified\n\nAuth: Session",
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/PasskeyRegistrationOptions"
        }
       }
      }
     },
     "400": {
      "description": "{ error, code?, correlationId } when the token is missing or expired (the identity service answers 401; the gateway returns it as 400)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "'Invalid session type' or enrolment not allowed for this session"
     },
     "404": {
      "description": "user not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-typed-objects": [
     "PasskeyRegistrationOptions"
    ],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/webauthn/register/verify": {
   "post": {
    "operationId": "post_auth_webauthn_register_verify",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Verify the attestation and store the passkey credential.",
    "description": "Request, as the catalogue words it: attestation as whole body, or { attestation } when { mfaToken } rides alongside; token from mfaToken or Authorization Bearer.\n\nReply, as the catalogue words it: 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\n\nGuard: isEnrollmentTokenType + denyUnlessMayEnroll; server-stored one-shot challenge reg_<userId> consumed regardless of outcome; enables MFA, clears staged TOTP secret\n\nAuth: Session",
    "requestBody": {
     "description": "attestation as whole body, or { attestation } when { mfaToken } rides alongside; token from mfaToken or Authorization Bearer.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ verified:true, recoveryCodes } (+ token when enrolling via mfaToken; codes only if account had none)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "verified": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "recoveryCodes"
         ]
        }
       }
      }
     },
     "400": {
      "description": "challenge expired / verification failed | { error, code?, correlationId } when the token is missing or expired (the identity service answers 401; the gateway returns it as 400)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "wrong session type"
     },
     "404": {
      "description": "user not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/webauthn/authenticate/generate": {
   "get": {
    "operationId": "get_auth_webauthn_authenticate_generate",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Get a WebAuthn authentication challenge.",
    "description": "Request, as the catalogue words it: Query: ?mfaToken= required (the login-challenge token)\n\nReply, as the catalogue words it: 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'\n\nAuth: Public (login challenge)",
    "responses": {
     "200": {
      "description": "WebAuthn authentication options (challenge, allowCredentials = the user's passkeys, userVerification 'required')"
     },
     "400": {
      "description": "{ error, code?, correlationId } when mfaToken is missing or expired (the identity service answers 401; the gateway returns it as 400)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "not an MFA-session token"
     },
     "404": {
      "description": "user not found, or 'No WebAuthn credentials found for user'"
     }
    },
    "security": [],
    "x-auth": "Public (login challenge)",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/webauthn/authenticate/verify": {
   "post": {
    "operationId": "post_auth_webauthn_authenticate_verify",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Verify the assertion and complete passkey sign-in.",
    "description": "Request, as the catalogue words it: { mfaToken, response } required — mfaToken the login-challenge JWT, response the WebAuthn assertion (response.id selects the credential).\n\nReply, as the catalogue words it: 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\n\nGuard: one-shot challenge auth_<userId> consumed before verify (no retry); credential scoped to user.id; counter advanced on success\n\nAuth: Public (login challenge)",
    "requestBody": {
     "description": "{ mfaToken, response } required — mfaToken the login-challenge JWT, response the WebAuthn assertion (response.id selects the credential).",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ auth:true, token }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "auth": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "token"
         ]
        }
       }
      }
     },
     "400": {
      "description": "'Passkey response missing', 'Challenge expired or not found' or the verifier's error | { error, code?, correlationId } for a missing or expired mfaToken and for 'Verification failed' (the identity service answers 401; the gateway returns it as 400)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "not an MFA-session token"
     },
     "404": {
      "description": "user or credential not found"
     }
    },
    "security": [],
    "x-auth": "Public (login challenge)",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/webauthn/login/options": {
   "get": {
    "operationId": "get_auth_webauthn_login_options",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Start a passkey sign-in with no username typed: a challenge any passkey registered on this instance can answer.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 WebAuthn authentication options (challenge, rpId, userVerification 'required'; no allowCredentials, so the browser offers the passkeys it holds for this instance)\n\nGuard: the challenge is stored server-side and must come back in POST /auth/webauthn/login/verify\n\nAuth: Public",
    "responses": {
     "200": {
      "description": "WebAuthn authentication options (challenge, rpId, userVerification 'required'; no allowCredentials, so the browser offers the passkeys it holds for this instance)"
     }
    },
    "security": [],
    "x-auth": "Public",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/webauthn/login/verify": {
   "post": {
    "operationId": "post_auth_webauthn_login_verify",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Finish a passkey sign-in. The passkey counts as both factors, so no separate MFA challenge follows.",
    "description": "Request, as the catalogue words it: The WebAuthn assertion as the whole body, or { response: <assertion> }\n\nReply, as the catalogue words it: 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\n\nGuard: this route forwards the identity service's status unchanged, so a 401 here is a 401 (unlike the MFA-challenge routes above)\n\nAuth: Public",
    "requestBody": {
     "description": "The WebAuthn assertion as the whole body, or { response: <assertion> }",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ 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 }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "'Malformed passkey assertion.' / 'Malformed client data.' / 'Challenge expired or not found. Please try again.' / the verifier's error"
     },
     "401": {
      "description": "{ error, code:'UNKNOWN_PASSKEY' } for a passkey this instance does not know, 401 'Passkey verification failed.'",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "when the account is locked or not active"
     }
    },
    "security": [],
    "x-auth": "Public",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/webauthn/credentials": {
   "get": {
    "operationId": "get_auth_webauthn_credentials",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "List your registered passkeys.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nGuard: returns everything (no paging); never returns key material\n\nAuth: Session",
    "responses": {
     "200": {
      "description": "a PasskeyList { credentials:[{ id, transports, createdAt }] (oldest first, every passkey in one reply), totpEnrolled:boolean, recoveryCodesRemaining:number }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/PasskeyList"
        }
       }
      }
     },
     "400": {
      "description": "{ error, code?, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "'Invalid session type' (an MFA-challenge, magic-link or password-change token)"
     },
     "404": {
      "description": "user not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-typed-objects": [
     "PasskeyList"
    ],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/webauthn/credentials/{id}": {
   "delete": {
    "operationId": "delete_auth_webauthn_credentials_id",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Remove one of your passkeys.",
    "description": "Request, as the catalogue words it: No body. Caller resolved from own session token (Authorization Bearer or cookie).\n\nReply, as the catalogue words it: 200 { ok:true } | 401 SUDO_REQUIRED | 403 MFA_VERIFICATION_REQUIRED | 404 passkey not found | 409 LAST_FACTOR\n\nGuard: 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)\n\nAuth: Session + sudo",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: WebAuthnCredential primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ ok:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "SUDO_REQUIRED"
     },
     "403": {
      "description": "MFA_VERIFICATION_REQUIRED"
     },
     "404": {
      "description": "passkey not found"
     },
     "409": {
      "description": "LAST_FACTOR"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + sudo",
    "x-permissions-named": [],
    "x-step-up": "required",
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/webauthn/sudo/available": {
   "get": {
    "operationId": "get_auth_webauthn_sudo_available",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Whether you have a passkey that can be used for step-up (sudo).",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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'\n\nAuth: Session",
    "responses": {
     "200": {
      "description": "{ available:boolean } (true when the caller has at least one passkey)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "available": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "{ error, code?, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "'Invalid session type'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/webauthn/sudo/options": {
   "get": {
    "operationId": "get_auth_webauthn_sudo_options",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Get a challenge for a passkey step-up.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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'\n\nGuard: 30 requests / 15 min per user (challenge budget)\n\nAuth: Session",
    "responses": {
     "200": {
      "description": "WebAuthn authentication options for the caller's passkeys"
     },
     "401": {
      "description": "no or expired session"
     },
     "403": {
      "description": "'Invalid session type'"
     },
     "404": {
      "description": "{ error:'NO_PASSKEY', message } when no passkey is enrolled",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/webauthn/sudo/verify": {
   "post": {
    "operationId": "post_auth_webauthn_sudo_verify",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Verify the passkey assertion and open the step-up window, exactly as POST /auth/sudo does with a password.",
    "description": "Request, as the catalogue words it: The WebAuthn assertion as the whole body (its id selects the credential)\n\nReply, as the catalogue words it: 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'\n\nGuard: 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\n\nAuth: Session",
    "requestBody": {
     "description": "The WebAuthn assertion as the whole body (its id selects the credential)",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ auth:true, token } — a sudo-stamped session token, also set as the session cookie",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "auth": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "token"
         ]
        }
       }
      }
     },
     "400": {
      "description": "'Challenge expired or not found' | code ASSERTION_NOT_VERIFIED | the verifier's error message"
     },
     "401": {
      "description": "no or expired session"
     },
     "403": {
      "description": "'Invalid session type'"
     },
     "404": {
      "description": "'Credential not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/devices": {
   "get": {
    "operationId": "get_auth_devices",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "List your trusted devices / active sessions.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nGuard: expired entries are purged before the list is built; returns everything (no paging)\n\nAuth: Session",
    "responses": {
     "200": {
      "description": "{ 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)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "devices": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/TrustedDevice"
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "{ error, code?, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "wrong session type"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-typed-objects": [
     "TrustedDevice"
    ],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/devices/all": {
   "delete": {
    "operationId": "delete_auth_devices_all",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Revoke every trusted device on your account.",
    "description": "Request, as the catalogue words it: No body. Caller resolved from own session token (Authorization Bearer or cookie).\n\nReply, as the catalogue words it: 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)\n\nGuard: session must be a full session (rejects mfaSession/magicLink/pwChange, requires username); scoped to caller id\n\nAuth: Session",
    "responses": {
     "200": {
      "description": "{ ok:true, revoked } — count of trusted devices dropped",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "revoked"
         ]
        }
       }
      }
     },
     "400": {
      "description": "{ error, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/devices/{id}": {
   "delete": {
    "operationId": "delete_auth_devices_id",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Revoke a single trusted device.",
    "description": "Request, as the catalogue words it: No body. Caller resolved from own session token.\n\nReply, as the catalogue words it: 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'\n\nGuard: destroy scoped to { id, userId: caller } so one user cannot revoke another's device\n\nAuth: Session",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "{ ok:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "{ error, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "wrong session type"
     },
     "404": {
      "description": "'Device not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/security/mfa/reset/{userId}": {
   "post": {
    "operationId": "post_auth_security_mfa_reset_userId",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Administratively reset another user's MFA enrolment.",
    "description": "Request, as the catalogue words it: No body. Target userId in path; actor read from x-user header injected by gateway.\n\nReply, as the catalogue words it: 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\n\nGuard: manage_security + sudo (requireSudo) at route; destructive — wipes passkeys, TOTP secrets, recovery codes AND all trusted devices; always audited\n\nAuth: Session + manage_security + sudo",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "a FactorResetResult { ok:true, email, passkeysRemoved, devicesRevoked }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/FactorResetResult"
        }
       }
      }
     },
     "400": {
      "description": "{ 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)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "without manage_security"
     },
     "404": {
      "description": "user not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security + sudo",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-step-up": "required",
    "x-typed-objects": [
     "FactorResetResult"
    ],
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/security/devices/revoke/{userId}": {
   "post": {
    "operationId": "post_auth_security_devices_revoke_userId",
    "tags": [
     "MFA, Passkeys & Trusted Devices"
    ],
    "summary": "Administratively revoke another user's trusted devices.",
    "description": "Request, as the catalogue words it: No body. Target userId in path; actor read from x-user header.\n\nReply, as the catalogue words it: 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\n\nGuard: manage_security + sudo (requireSudo) at route; revokes only trusted devices (keeps user's factors); audited\n\nAuth: Session + manage_security + sudo",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ ok:true, email, revoked }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "email",
          "revoked"
         ]
        }
       }
      }
     },
     "400": {
      "description": "{ 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)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "without manage_security"
     },
     "404": {
      "description": "user not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security + sudo",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "gw-mfa"
   }
  },
  "/auth/settings/security": {
   "get": {
    "operationId": "get_auth_settings_security",
    "tags": [
     "Security Settings, Access Control & Audit Sessions"
    ],
    "summary": "Read security settings (MFA policy, session timeout, password policy, SMTP).",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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 }\n\nGuard: 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\n\nAuth: Session + manage_security | view_users | view_dashboard",
    "responses": {
     "200": {
      "description": "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 }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/SecuritySettings"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security | view_users | view_dashboard",
    "x-permissions-named": [
     "manage_security",
     "view_dashboard",
     "view_users"
    ],
    "x-typed-objects": [
     "PasswordPolicy",
     "SecuritySettings"
    ],
    "x-catalogue-group": "gw-system"
   },
   "post": {
    "operationId": "post_auth_settings_security",
    "tags": [
     "Security Settings, Access Control & Audit Sessions"
    ],
    "summary": "Update security settings.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 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)\n\nGuard: 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\n\nAuth: Session + manage_security + sudo",
    "requestBody": {
     "description": "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",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "{ 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 | for an unknown reportRecipientPolicy; settings earlier in the same body may already have been saved | { 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)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "{ error:'License-managed setting', correlationId } when a module switch is not yours to change (the sentence naming the modules does not pass the gateway)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security + sudo",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "gw-system"
   }
  },
  "/auth/settings/security/smtp/test": {
   "post": {
    "operationId": "post_auth_settings_security_smtp_test",
    "tags": [
     "Security Settings, Access Control & Audit Sessions"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: { to (required), host?, port?, user?, pass?, from?, secure? }\n\nReply, as the catalogue words it: 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\n\nAuth: Session + manage_security",
    "requestBody": {
     "description": "{ to (required), host?, port?, user?, pass?, from?, secure? }",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success:true, messageId, message }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "messageId",
          "message"
         ]
        }
       }
      }
     },
     "400": {
      "description": "{ error, correlationId } when to is missing ('A recipient email address is required.') or no mail server is configured",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "424": {
      "description": "{ success:false, error } when the mail server refused the message or could not be reached; error carries the mail server's reason",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-catalogue-group": "gw-system"
   }
  },
  "/auth/settings/security/access-control": {
   "get": {
    "operationId": "get_auth_settings_security_access_control",
    "tags": [
     "Security Settings, Access Control & Audit Sessions"
    ],
    "summary": "Read the geo/IP access-control rule set.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nAuth: Session + manage_security",
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AccessControlRules"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-typed-objects": [
     "AccessControlRules"
    ],
    "x-catalogue-group": "gw-system"
   },
   "post": {
    "operationId": "post_auth_settings_security_access_control",
    "tags": [
     "Security Settings, Access Control & Audit Sessions"
    ],
    "summary": "Update geo/IP access rules (mode, countries, IP lists, dry run).",
    "description": "Request, as the catalogue words it: All coerced/optional: enabled bool, mode 'allowlist'|'blocklist' (anything else->blocklist), countries[], blockedIps[], allowedIps[], blockUnknownGeo bool, dryRun bool\n\nReply, as the catalogue words it: 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)\n\nGuard: Session + manage_security + a fresh step-up; non-array list fields default to []; unknown mode coerced to blocklist\n\nAuth: Session + manage_security + sudo",
    "requestBody": {
     "description": "All coerced/optional: enabled bool, mode 'allowlist'|'blocklist' (anything else->blocklist), countries[], blockedIps[], allowedIps[], blockUnknownGeo bool, dryRun bool",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok:true } (writes ACCESS_CONTROL_UPDATED audit log)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "{ 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)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security + sudo",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "gw-system"
   }
  },
  "/auth/settings/security/access-control/invalidate-cache": {
   "post": {
    "operationId": "post_auth_settings_security_access_control_invalidate_cache",
    "tags": [
     "Security Settings, Access Control & Audit Sessions"
    ],
    "summary": "Force the gateway to reload access rules instead of waiting for the 60s cache.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { ok:true }\n\nGuard: Session + manage_security; handled at gateway (calls invalidateGeoCache), never proxied downstream\n\nAuth: Session + manage_security",
    "responses": {
     "200": {
      "description": "{ ok:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-catalogue-group": "gw-system"
   }
  },
  "/auth/log": {
   "post": {
    "operationId": "post_auth_log",
    "tags": [
     "Security Settings, Access Control & Audit Sessions"
    ],
    "summary": "Write a compliance/audit-execution event to the central audit log from the app.",
    "description": "Request, as the catalogue words it: { action, details } required; level optional ('info' default); payload optional object\n\nReply, as the catalogue words it: 200 { ok:true }\n\nGuard: 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\n\nAuth: Session + manage_compliance",
    "requestBody": {
     "description": "{ action, details } required; level optional ('info' default); payload optional object",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "gw-system"
   }
  },
  "/auth/audits/history": {
   "get": {
    "operationId": "get_auth_audits_history",
    "tags": [
     "Security Settings, Access Control & Audit Sessions"
    ],
    "summary": "List historical audit records.",
    "description": "Request, as the catalogue words it: 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)\n\nReply, as the catalogue words it: 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\n\nAuth: Session + manage_compliance | view_reporting",
    "parameters": [
     {
      "name": "summary",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "Any value selects the paged summaries."
     },
     {
      "name": "offset",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 0,
       "minimum": 0
      },
      "description": "Read only together with summary. The page size is fixed at 100. A value outside the stated range is moved to the nearest bound, not refused."
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "truncated": {
           "type": "boolean",
           "description": "another page exists"
          },
          "items": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "id",
             "name",
             "framework",
             "startDate",
             "endDate",
             "completedDate",
             "completedBy",
             "score",
             "sealed",
             "sealValid",
             "sealedAt",
             "verifiedScore",
             "evidenceCount"
            ]
           }
          }
         },
         "x-keys-not-described": [
          "nextOffset"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance | view_reporting",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-paging-parameters": [
     "offset"
    ],
    "x-catalogue-group": "gw-system"
   },
   "post": {
    "operationId": "post_auth_audits_history",
    "tags": [
     "Security Settings, Access Control & Audit Sessions"
    ],
    "summary": "Save a historical audit record.",
    "description": "Request, as the catalogue words it: { id, name, framework, startDate, completedDate, completedBy } + completedItems[] (each {id, evidenceDetails{status,notes,rawData,attachmentUrl}}), totalControls optional\n\nReply, as the catalogue words it: 200 { AuditRecord } (finalized/completed; the gateway answers 200 whatever the service status)\n\nGuard: 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)\n\nAuth: Session + manage_compliance",
    "requestBody": {
     "description": "{ id, name, framework, startDate, completedDate, completedBy } + completedItems[] (each {id, evidenceDetails{status,notes,rawData,attachmentUrl}}), totalControls optional",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ AuditRecord } (finalized/completed; the gateway answers 200 whatever the service status)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "gw-system"
   }
  },
  "/auth/audits/history/{id}": {
   "get": {
    "operationId": "get_auth_audits_history_id",
    "tags": [
     "Security Settings, Access Control & Audit Sessions"
    ],
    "summary": "One completed audit in full (evidence rows and sealed verdicts).",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { id, name, framework, startDate, endDate, completedDate, completedBy, score, sealed, sealValid, sealedAt, sealedVerdicts, sealedScope, verifiedScore, completedItems:[…evidence rows] } | 404 'Audit record not found'\n\nAuth: Session + manage_compliance | view_reporting",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Typed from the primary key of the record it names: the AuditRecord primary key is a string.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ id, name, framework, startDate, endDate, completedDate, completedBy, score, sealed, sealValid, sealedAt, sealedVerdicts, sealedScope, verifiedScore, completedItems:[…evidence rows] }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "'Audit record not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance | view_reporting",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-catalogue-group": "gw-system"
   },
   "delete": {
    "operationId": "delete_auth_audits_history_id",
    "tags": [
     "Security Settings, Access Control & Audit Sessions"
    ],
    "summary": "Delete a historical audit record.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { ok:true, deleted:id } | 404 not found | 409 if record is COMPLETED (immutable)\n\nGuard: Session + manage_compliance; only DRAFT (non-completed) records deletable — completed audit evidence is immutable, refusal itself is logged\n\nAuth: Session + manage_compliance",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Typed from the primary key of the record it names: the AuditRecord primary key is a string.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ ok:true, deleted:id }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "deleted"
         ]
        }
       }
      }
     },
     "404": {
      "description": "not found"
     },
     "409": {
      "description": "if record is COMPLETED (immutable)"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "gw-system"
   }
  },
  "/auth/audits/active": {
   "get": {
    "operationId": "get_auth_audits_active",
    "tags": [
     "Security Settings, Access Control & Audit Sessions"
    ],
    "summary": "List in-progress audit sessions.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nGuard: bare array, no paging\n\nAuth: Session + manage_compliance",
    "responses": {
     "200": {
      "description": "[ { id, name, framework, startDate, endDate, completedDate (null), score, completedItems:[{ id, title, evidenceDetails:{ status, notes, … } }] } ], newest startDate first; the standing continuous-monitoring record is excluded",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "type": "object",
          "properties": {
           "completedItems": {
            "type": "array",
            "items": {
             "type": "object",
             "properties": {
              "evidenceDetails": {
               "type": "object",
               "x-keys-not-described": [
                "status",
                "notes"
               ]
              }
             },
             "x-keys-not-described": [
              "id",
              "title"
             ]
            }
           }
          },
          "x-keys-not-described": [
           "id",
           "name",
           "framework",
           "startDate",
           "endDate",
           "completedDate",
           "score"
          ]
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "gw-system"
   },
   "post": {
    "operationId": "post_auth_audits_active",
    "tags": [
     "Security Settings, Access Control & Audit Sessions"
    ],
    "summary": "Create or update an in-progress audit session.",
    "description": "Request, as the catalogue words it: { id, name, framework, score, startDate, completedBy } + endDate optional, completedItems[] (each {id, evidenceDetails{status,notes,rawData,attachmentUrl}})\n\nReply, as the catalogue words it: 200 { AuditRecord } (completedDate forced null = in-progress)\n\nGuard: Session + manage_compliance; upsert by id; autosave preserves harvester evidence tagged '[ACTIVE VERIFICATION]', wiping only manual evidence before re-insert\n\nAuth: Session + manage_compliance",
    "requestBody": {
     "description": "{ id, name, framework, score, startDate, completedBy } + endDate optional, completedItems[] (each {id, evidenceDetails{status,notes,rawData,attachmentUrl}})",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ AuditRecord } (completedDate forced null = in-progress)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "gw-system"
   }
  },
  "/auth/audits/active/{id}": {
   "delete": {
    "operationId": "delete_auth_audits_active_id",
    "tags": [
     "Security Settings, Access Control & Audit Sessions"
    ],
    "summary": "End an in-progress audit session.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { ok:true, deleted:id } | 404 not found\n\nGuard: Session + manage_compliance; only deletes records with completedDate=null (in-progress); cascades AuditEvidence delete\n\nAuth: Session + manage_compliance",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "{ ok:true, deleted:id }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "deleted"
         ]
        }
       }
      }
     },
     "404": {
      "description": "not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "gw-system"
   }
  },
  "/health/cluster": {
   "get": {
    "operationId": "get_health_cluster",
    "tags": [
     "Security Settings, Access Control & Audit Sessions"
    ],
    "summary": "Aggregate health of the instance (operational / degraded / maintenance). Deliberately returns no per-service detail to anonymous callers.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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)\n\nGuard: services whose module is not licensed are not probed and do not count against the status\n\nAuth: Public",
    "responses": {
     "200": {
      "description": "{ status:'operational', timestamp:string (ISO 8601 date-time) } when every expected service is up",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "timestamp": {
           "type": "string",
           "description": "ISO 8601 date-time"
          }
         },
         "x-keys-not-described": [
          "status"
         ]
        }
       }
      }
     },
     "503": {
      "description": "{ status:'degraded'|'maintenance', timestamp:string } otherwise ('maintenance' = no expected service is up or degraded)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-system"
   }
  },
  "/health": {
   "get": {
    "operationId": "get_health",
    "tags": [
     "Security Settings, Access Control & Audit Sessions"
    ],
    "summary": "Liveness of the API gateway process only. It says nothing about the other services; use /health/cluster for that.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { status:'UP', service:'api-gateway' }\n\nAuth: Public",
    "responses": {
     "200": {
      "description": "{ status:'UP', service:'api-gateway' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public",
    "x-permissions-named": [],
    "x-catalogue-group": "gw-system"
   }
  },
  "/user-management/users": {
   "get": {
    "operationId": "get_user_management_users",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 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\n\nAuth: Session + view_users | manage_users | manage_permissions | manage_workflows | view_dashboard",
    "parameters": [
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 50,
       "minimum": 1,
       "maximum": 500
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "offset",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 0,
       "minimum": 0
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "status",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "source",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "mfa",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "role",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "A role name."
     },
     {
      "name": "manager",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "search",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/UserPage"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_users | manage_users | manage_permissions | manage_workflows | view_dashboard",
    "x-permissions-named": [
     "manage_permissions",
     "manage_users",
     "manage_workflows",
     "view_dashboard",
     "view_users"
    ],
    "x-paging-parameters": [
     "limit",
     "offset"
    ],
    "x-typed-objects": [
     "User",
     "UserCard",
     "UserPage"
    ],
    "x-catalogue-group": "users"
   },
   "post": {
    "operationId": "post_user_management_users",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Create a user.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_users",
    "requestBody": {
     "description": "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",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/UserCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "created User (with sendInvite:true the same object plus an invite field)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/User"
        }
       }
      }
     },
     "400": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "privilege escalation"
     },
     "409": {
      "description": "a user with that email or login name already exists"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users",
    "x-permissions-named": [
     "manage_users"
    ],
    "x-typed-objects": [
     "User"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/users/search": {
   "get": {
    "operationId": "get_user_management_users_search",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Typeahead user lookup for pickers.",
    "description": "Request, as the catalogue words it: Query: ?query= (up to 6 words, each matched against email, firstName, lastName, sAMAccountName)\n\nReply, as the catalogue words it: 200 [ { id, firstName, lastName, email, sAMAccountName, status } ] — at most 10, ordered by firstName, lastName, id; an empty query answers []\n\nGuard: no paging; narrow the query instead\n\nAuth: Session + view_users | manage_groups | manage_roles | manage_employee_records",
    "responses": {
     "200": {
      "description": "[ { id, firstName, lastName, email, sAMAccountName, status } ] — at most 10, ordered by firstName, lastName, id; an empty query answers []",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_users | manage_groups | manage_roles | manage_employee_records",
    "x-permissions-named": [
     "manage_employee_records",
     "manage_groups",
     "manage_roles",
     "view_users"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/users/stats": {
   "get": {
    "operationId": "get_user_management_users_stats",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Aggregate user counts for dashboard tiles.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { total, active, mfaEnabled, synced (source other than local or onboarding), serviceAccounts, mfaEligible (active accounts that can sign in), mfaEligibleEnabled }\n\nAuth: Session + view_users | manage_users | view_dashboard",
    "responses": {
     "200": {
      "description": "{ total, active, mfaEnabled, synced (source other than local or onboarding), serviceAccounts, mfaEligible (active accounts that can sign in), mfaEligibleEnabled }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_users | manage_users | view_dashboard",
    "x-permissions-named": [
     "manage_users",
     "view_dashboard",
     "view_users"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/users/me/export": {
   "get": {
    "operationId": "get_user_management_users_me_export",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Export your own profile, groups, roles, and most recent audit entries as JSON (GDPR access request).",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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'\n\nGuard: each export is audit-logged as PEOPLE_DATA_EXPORTED\n\nAuth: Session",
    "responses": {
     "200": {
      "description": "{ 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 }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "profile": {
           "type": "object",
           "x-keys-not-described": [
            "id",
            "firstName",
            "lastName",
            "email",
            "username",
            "status",
            "department",
            "mfaEnabled",
            "attributes",
            "createdAt",
            "updatedAt"
           ]
          },
          "groups": {
           "type": "array"
          },
          "roles": {
           "type": "array"
          },
          "permissions": {
           "type": "array"
          }
         },
         "x-keys-not-described": [
          "exportedAt",
          "requestingUser",
          "recentAuditLogs",
          "employeeRecord",
          "crossServiceData"
         ]
        }
       }
      }
     },
     "404": {
      "description": "'User profile not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/users/{id}": {
   "put": {
    "operationId": "put_user_management_users_id",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Update a user.",
    "description": "Request, as the catalogue words it: 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[]\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_users (+ manage_security and sudo when the body sets a password)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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[]",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/UserUpdate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "User",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/User"
        }
       }
      }
     },
     "400": {
      "description": "name taken/self-manage/cycle/pwd-policy"
     },
     "403": {
      "description": "primaryAdmin immutable / reactivation|isPrimaryAdmin need manage_security / roles need manage_roles / groups need manage_groups"
     },
     "404": {
      "description": "not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users (+ manage_security and sudo when the body sets a password)",
    "x-permissions-named": [
     "manage_security",
     "manage_users"
    ],
    "x-step-up": "conditional",
    "x-typed-objects": [
     "User"
    ],
    "x-catalogue-group": "users"
   },
   "delete": {
    "operationId": "delete_user_management_users_id",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Delete a user and their personal records.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nGuard: an account whose status is active is refused; offboard it first, then delete\n\nAuth: Session + manage_users",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ deleted:true, erasure } | { anonymized:true, message, crossServiceErasure, erasure } when other records still reference the user, so the personal fields are overwritten and the row is kept",
      "content": {
       "application/json": {
        "schema": {
         "anyOf": [
          {
           "type": "object",
           "properties": {
            "deleted": {
             "type": "boolean"
            }
           },
           "x-keys-not-described": [
            "erasure"
           ]
          },
          {
           "type": "object",
           "properties": {
            "anonymized": {
             "type": "boolean"
            }
           },
           "x-keys-not-described": [
            "message",
            "crossServiceErasure",
            "erasure"
           ]
          }
         ]
        }
       }
      }
     },
     "403": {
      "description": "system administrator account cannot be deleted"
     },
     "404": {
      "description": "not found"
     },
     "409": {
      "description": "the user is still active (offboard first) | code SUPPORT_ACCOUNT_MANAGED for the Zonitix support account"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users",
    "x-permissions-named": [
     "manage_users"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/users/{id}/offboard": {
   "post": {
    "operationId": "post_user_management_users_id_offboard",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Start the offboarding flow (deprovision + dated reclaim tasks).",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_users",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/UserOffboard"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "task"
     },
     "202": {
      "description": "PARTIAL (access revoked but no deprovisioning run created; or, with immediate:true, the forced revocation left something outstanding)"
     },
     "400": {
      "description": "{ error:'An immediate termination needs a reason on the record.' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "cannot offboard your own account"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users",
    "x-permissions-named": [
     "manage_users"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/users/org-tree": {
   "get": {
    "operationId": "get_user_management_users_org_tree",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Org chart hierarchy.",
    "description": "Request, as the catalogue words it: Query: ?format=csv returns the directory as a CSV download instead of JSON\n\nReply, as the catalogue words it: 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\n\nGuard: bare array, no paging; a CSV export is audit-logged\n\nAuth: Session + view_org_chart | view_dashboard",
    "responses": {
     "200": {
      "description": "[ 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "?format=csv without one of those permissions"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_org_chart | view_dashboard",
    "x-permissions-named": [
     "view_dashboard",
     "view_org_chart"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/users/org-rollups": {
   "get": {
    "operationId": "get_user_management_users_org_rollups",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Rolled-up headcount/health metrics per branch of the org chart.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { totalHeadcount, departmentCount, departments:[{ department, headcount, managers, ics }] } — active users only, largest department first; a user with no department is counted under 'Unassigned'\n\nAuth: Session + view_org_chart | view_dashboard",
    "responses": {
     "200": {
      "description": "{ totalHeadcount, departmentCount, departments:[{ department, headcount, managers, ics }] } — active users only, largest department first; a user with no department is counted under 'Unassigned'",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "departments": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "department",
             "headcount",
             "managers",
             "ics"
            ]
           }
          }
         },
         "x-keys-not-described": [
          "totalHeadcount",
          "departmentCount"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_org_chart | view_dashboard",
    "x-permissions-named": [
     "view_dashboard",
     "view_org_chart"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/users/org-vacancies": {
   "get": {
    "operationId": "get_user_management_users_org_vacancies",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Open positions surfaced on the org chart.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { total, byManager:{ [hiringManagerId]: count }, byDepartment:{ [department]: count } } — counts job postings whose status is Open or Published\n\nAuth: Session + view_org_chart | view_dashboard",
    "responses": {
     "200": {
      "description": "{ total, byManager:{ [hiringManagerId]: count }, byDepartment:{ [department]: count } } — counts job postings whose status is Open or Published",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "byManager": {
           "type": "object"
          },
          "byDepartment": {
           "type": "object"
          }
         },
         "x-keys-not-described": [
          "total"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_org_chart | view_dashboard",
    "x-permissions-named": [
     "view_dashboard",
     "view_org_chart"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/users/{id}/dotted-reports": {
   "put": {
    "operationId": "put_user_management_users_id_dotted_reports",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Set matrix (dotted-line) reporting relationships.",
    "description": "Request, as the catalogue words it: A DottedReportsUpdate: { managerIds: number[] } REPLACES the set — deduped, self removed, max 10, each must be an active user\n\nReply, as the catalogue words it: 200 { id, dottedManagerIds } | 404 user not found | 400 >10 managers / one or more not valid active users\n\nGuard: stored in user.attributes.dottedManagerIds; route gated manage_org_chart; safe to repeat (the set is replaced, not appended)\n\nAuth: Session + manage_org_chart",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A DottedReportsUpdate: { managerIds: number[] } REPLACES the set — deduped, self removed, max 10, each must be an active user",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DottedReportsUpdate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ id, dottedManagerIds }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": ">10 managers / one or more not valid active users"
     },
     "404": {
      "description": "user not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_org_chart",
    "x-permissions-named": [
     "manage_org_chart"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/groups": {
   "get": {
    "operationId": "get_user_management_groups",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "List groups.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 [ Group ] (typed under Response Objects) — each with Users:[{ id:integer }]\n\nGuard: bare array, no paging or sort parameter\n\nAuth: Session + view_groups | manage_groups | view_dashboard",
    "responses": {
     "200": {
      "description": "[ Group ] (typed under Response Objects) — each with Users:[{ id:integer }]",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/Group"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_groups | manage_groups | view_dashboard",
    "x-permissions-named": [
     "manage_groups",
     "view_dashboard",
     "view_groups"
    ],
    "x-typed-objects": [
     "Group"
    ],
    "x-catalogue-group": "users"
   },
   "post": {
    "operationId": "post_user_management_groups",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Create a group.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 201 created Group | 409 a group with that name already exists\n\nGuard: 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\n\nAuth: Session + manage_groups",
    "requestBody": {
     "description": "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",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/GroupWrite"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "created Group",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Group"
        }
       }
      }
     },
     "409": {
      "description": "a group with that name already exists"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_groups",
    "x-permissions-named": [
     "manage_groups"
    ],
    "x-typed-objects": [
     "Group"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/groups/{id}": {
   "put": {
    "operationId": "put_user_management_groups_id",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Update a group.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 200 Group | 404 not found\n\nGuard: the MFA keys writable only by manage_security caller (delegated manage_groups cannot downgrade MFA)\n\nAuth: Session + manage_groups",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Group primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/GroupWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Group",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Group"
        }
       }
      }
     },
     "404": {
      "description": "not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_groups",
    "x-permissions-named": [
     "manage_groups"
    ],
    "x-typed-objects": [
     "Group"
    ],
    "x-catalogue-group": "users"
   },
   "delete": {
    "operationId": "delete_user_management_groups_id",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Delete a group.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 204 no content | 404 not found\n\nAuth: Session + manage_groups",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Group primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "204": {
      "description": "no content"
     },
     "404": {
      "description": "not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_groups",
    "x-permissions-named": [
     "manage_groups"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/roles": {
   "get": {
    "operationId": "get_user_management_roles",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "List roles.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 [ Role ] (typed under Response Objects) — each with Permissions[], Users:[{ id:integer }] and Groups:[{ id:integer, name:string }]\n\nGuard: bare array, no paging or sort parameter\n\nAuth: Session + view_roles | manage_roles | view_dashboard",
    "responses": {
     "200": {
      "description": "[ Role ] (typed under Response Objects) — each with Permissions[], Users:[{ id:integer }] and Groups:[{ id:integer, name:string }]",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/Role"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_roles | manage_roles | view_dashboard",
    "x-permissions-named": [
     "manage_roles",
     "view_dashboard",
     "view_roles"
    ],
    "x-typed-objects": [
     "Role"
    ],
    "x-catalogue-group": "users"
   },
   "post": {
    "operationId": "post_user_management_roles",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Create a role.",
    "description": "Request, as the catalogue words it: A RoleWrite: name, description, userIds[], permissionIds[], groupIds[]\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_roles",
    "requestBody": {
     "description": "A RoleWrite: name, description, userIds[], permissionIds[], groupIds[]",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/RoleWrite"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "created Role",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Role"
        }
       }
      }
     },
     "403": {
      "description": "only administrator can create 'administrator' role / privilege escalation (perms you do not hold)"
     },
     "409": {
      "description": "a role with that name already exists"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_roles",
    "x-permissions-named": [
     "manage_roles"
    ],
    "x-typed-objects": [
     "Role"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/roles/{id}": {
   "put": {
    "operationId": "put_user_management_roles_id",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Update a role.",
    "description": "Request, as the catalogue words it: A RoleWrite: name, description, userIds[], permissionIds[], groupIds[]\n\nReply, as the catalogue words it: 200 Role | 404 not found | 403 rename crossing 'administrator' / grant perms you lack / assign members to over-privileged role / admin-role membership needs administrator\n\nGuard: name pinned on entry to block rename-bypass; permissionIds subset invariant; administrator role always reset to ALL perms\n\nAuth: Session + manage_roles",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Role primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A RoleWrite: name, description, userIds[], permissionIds[], groupIds[]",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/RoleWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Role",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Role"
        }
       }
      }
     },
     "403": {
      "description": "rename crossing 'administrator' / grant perms you lack / assign members to over-privileged role / admin-role membership needs administrator"
     },
     "404": {
      "description": "not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_roles",
    "x-permissions-named": [
     "manage_roles"
    ],
    "x-typed-objects": [
     "Role"
    ],
    "x-catalogue-group": "users"
   },
   "delete": {
    "operationId": "delete_user_management_roles_id",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Delete a role.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 204 no content | 404 not found | 403 the primary administrator role cannot be deleted\n\nAuth: Session + manage_roles",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Role primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "204": {
      "description": "no content"
     },
     "403": {
      "description": "the primary administrator role cannot be deleted"
     },
     "404": {
      "description": "not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_roles",
    "x-permissions-named": [
     "manage_roles"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/roles/{roleId}/permissions": {
   "post": {
    "operationId": "post_user_management_roles_roleId_permissions",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Replace a role's permission set.",
    "description": "Request, as the catalogue words it: A RolePermissionsReplace: { permissionIds: number[] } REPLACES the role's permission set\n\nReply, as the catalogue words it: 200 { ok:true } | 404 role not found | 403 privilege escalation (cannot grant perms you do not hold)\n\nGuard: subset invariant via permissionsCallerMayNotGrant; administrator role always forced to ALL perms; safe to repeat (replace semantics)\n\nAuth: Session + manage_roles",
    "parameters": [
     {
      "name": "roleId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Role primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A RolePermissionsReplace: { permissionIds: number[] } REPLACES the role's permission set",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/RolePermissionsReplace"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "403": {
      "description": "privilege escalation (cannot grant perms you do not hold)"
     },
     "404": {
      "description": "role not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_roles",
    "x-permissions-named": [
     "manage_roles"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/permissions": {
   "get": {
    "operationId": "get_user_management_permissions",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "List every atomic permission.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 [ Permission ] (typed under Response Objects) — each with Roles[] and Users:[{ id:integer }]\n\nGuard: bare array, no paging\n\nAuth: Session + manage_permissions | manage_roles",
    "responses": {
     "200": {
      "description": "[ Permission ] (typed under Response Objects) — each with Roles[] and Users:[{ id:integer }]",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/Permission"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_permissions | manage_roles",
    "x-permissions-named": [
     "manage_permissions",
     "manage_roles"
    ],
    "x-typed-objects": [
     "Permission"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/permissions/{id}/assignments": {
   "post": {
    "operationId": "post_user_management_permissions_id_assignments",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Set which roles/users hold a given permission.",
    "description": "Request, as the catalogue words it: { roleIds?: number[], userIds?: number[] } — each REPLACES that side of the join for this permission\n\nReply, as the catalogue words it: 200 { ok:true } | 404 permission not found | 403 you cannot assign a permission you do not hold\n\nGuard: caller must already hold the permission (subset); administrator role is always re-added to it\n\nAuth: Session + manage_permissions",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Permission primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "{ roleIds?: number[], userIds?: number[] } — each REPLACES that side of the join for this permission",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "roleIds": {
          "type": "array",
          "items": {
           "type": "number"
          }
         },
         "userIds": {
          "type": "array",
          "items": {
           "type": "number"
          }
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "403": {
      "description": "you cannot assign a permission you do not hold"
     },
     "404": {
      "description": "permission not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_permissions",
    "x-permissions-named": [
     "manage_permissions"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/permissions/compliance-access/{userId}": {
   "post": {
    "operationId": "post_user_management_permissions_compliance_access_userId",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Grant a named user read access to compliance evidence (e.g. an external auditor you have onboarded as a user).",
    "description": "Request, as the catalogue words it: No body. Grants the view_assigned_compliance permission to :userId.\n\nReply, as the catalogue words it: 200 { ok:true } | 404 user not found | 400 view_assigned_compliance permission not seeded\n\nGuard: direct user->permission grant (addPermission), scoped to the one user\n\nAuth: Session + manage_compliance",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ ok:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "view_assigned_compliance permission not seeded"
     },
     "404": {
      "description": "user not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "users"
   },
   "delete": {
    "operationId": "delete_user_management_permissions_compliance_access_userId",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Revoke that compliance access.",
    "description": "Request, as the catalogue words it: No body. Removes view_assigned_compliance from :userId.\n\nReply, as the catalogue words it: 200 { ok:true } | 404 user not found\n\nGuard: no-op if the permission is not seeded (removePermission only when found)\n\nAuth: Session + manage_compliance",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ ok:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "user not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/personas": {
   "get": {
    "operationId": "get_user_management_personas",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "List onboarding personas.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 [ UserPersona ], ordered by name\n\nGuard: bare array, no paging\n\nAuth: Session + view_users | manage_users | manage_personas",
    "responses": {
     "200": {
      "description": "[ UserPersona ], ordered by name",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_users | manage_users | manage_personas",
    "x-permissions-named": [
     "manage_personas",
     "manage_users",
     "view_users"
    ],
    "x-catalogue-group": "users"
   },
   "post": {
    "operationId": "post_user_management_personas",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Create a persona.",
    "description": "Request, as the catalogue words it: name (required, unique), description, hardwareTemplate{}, licenseTemplate[], accessRequirements[], hardwareProvisioning[], providerMapping{}\n\nReply, as the catalogue words it: 201 created UserPersona (estimatedOneTimeCost/estimatedMonthlyCost computed server-side) | 409 a persona with that name already exists\n\nGuard: service spreads req.body then recomputes cost totals from hardwareTemplate/licenseTemplate; a repeated create with the same name is refused with 409\n\nAuth: Session + manage_users | manage_personas",
    "requestBody": {
     "description": "name (required, unique), description, hardwareTemplate{}, licenseTemplate[], accessRequirements[], hardwareProvisioning[], providerMapping{}",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "created UserPersona (estimatedOneTimeCost/estimatedMonthlyCost computed server-side)"
     },
     "409": {
      "description": "a persona with that name already exists"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users | manage_personas",
    "x-permissions-named": [
     "manage_personas",
     "manage_users"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/personas/{id}": {
   "put": {
    "operationId": "put_user_management_personas_id",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Update a persona.",
    "description": "Request, as the catalogue words it: same fields as create: name, description, hardwareTemplate{}, licenseTemplate[], accessRequirements[], hardwareProvisioning[], providerMapping{}\n\nReply, as the catalogue words it: 200 updated UserPersona (costs recomputed)\n\nGuard: service spreads req.body over the row (no explicit write allowlist); costs recomputed server-side\n\nAuth: Session + manage_users | manage_personas",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: UserPersona primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "same fields as create: name, description, hardwareTemplate{}, licenseTemplate[], accessRequirements[], hardwareProvisioning[], providerMapping{}",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "updated UserPersona (costs recomputed)"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users | manage_personas",
    "x-permissions-named": [
     "manage_personas",
     "manage_users"
    ],
    "x-catalogue-group": "users"
   },
   "delete": {
    "operationId": "delete_user_management_personas_id",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Delete a persona.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 204 no content\n\nAuth: Session + manage_users | manage_personas",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: UserPersona primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "204": {
      "description": "no content"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users | manage_personas",
    "x-permissions-named": [
     "manage_personas",
     "manage_users"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/personas/{id}/provisioning-plan": {
   "post": {
    "operationId": "post_user_management_personas_id_provisioning_plan",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Generate the provisioning plan a persona would produce.",
    "description": "Request, as the catalogue words it: { 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.\n\nReply, as the catalogue words it: 200 { persona:{ id, name }, cost:{ oneTime, monthly }, workflow:{ id, name } | null, startDate, items[], summary } | 404 persona not found\n\nGuard: classifies auto vs dated IT tasks against active ExternalProviders; writes nothing\n\nAuth: Session + view_users | manage_users | manage_personas | manage_workflows | manage_approvals",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: UserPersona primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "{ 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.",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "startDate": {
          "type": "string"
         }
        },
        "x-keys-not-described": [
         "workflowId"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ persona:{ id, name }, cost:{ oneTime, monthly }, workflow:{ id, name } | null, startDate, items[], summary }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "persona": {
           "type": "object",
           "x-keys-not-described": [
            "id",
            "name"
           ]
          },
          "cost": {
           "type": "object",
           "x-keys-not-described": [
            "oneTime",
            "monthly"
           ]
          },
          "workflow": {
           "type": [
            "object",
            "null"
           ],
           "x-keys-not-described": [
            "id",
            "name"
           ]
          },
          "items": {
           "type": "array"
          }
         },
         "x-keys-not-described": [
          "startDate",
          "summary"
         ]
        }
       }
      }
     },
     "404": {
      "description": "persona not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_users | manage_users | manage_personas | manage_workflows | manage_approvals",
    "x-permissions-named": [
     "manage_approvals",
     "manage_personas",
     "manage_users",
     "manage_workflows",
     "view_users"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/personas/preview/provisioning-plan": {
   "post": {
    "operationId": "post_user_management_personas_preview_provisioning_plan",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Preview a provisioning plan for an unsaved persona definition.",
    "description": "Request, as the catalogue words it: { persona: {...unsaved persona def}, startDate?: string }. Read-only — no persistence.\n\nReply, as the catalogue words it: 200 { startDate, items[], summary }\n\nGuard: live-preview for an unsaved persona; writes nothing\n\nAuth: Session + view_users | manage_users | manage_personas | manage_workflows",
    "requestBody": {
     "description": "{ persona: {...unsaved persona def}, startDate?: string }. Read-only — no persistence.",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "persona": {
          "type": "object"
         },
         "startDate": {
          "type": "string"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ startDate, items[], summary }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "items": {
           "type": "array"
          }
         },
         "x-keys-not-described": [
          "startDate",
          "summary"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_users | manage_users | manage_personas | manage_workflows",
    "x-permissions-named": [
     "manage_personas",
     "manage_users",
     "manage_workflows",
     "view_users"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/org-snapshots": {
   "get": {
    "operationId": "get_user_management_org_snapshots",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "List saved org-chart snapshots.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { snapshots:[{ id, name, snapshotDate, createdBy, metrics, createdAt }] (newest snapshotDate first; the captured tree is not included), metadata:{ nextScheduledSnapshot } }\n\nGuard: returns every snapshot (no paging)\n\nAuth: Session + view_org_chart",
    "responses": {
     "200": {
      "description": "{ snapshots:[{ id, name, snapshotDate, createdBy, metrics, createdAt }] (newest snapshotDate first; the captured tree is not included), metadata:{ nextScheduledSnapshot } }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "snapshots": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "id",
             "name",
             "snapshotDate",
             "createdBy",
             "metrics",
             "createdAt"
            ]
           },
           "description": "newest snapshotDate first; the captured tree is not included"
          },
          "metadata": {
           "type": "object",
           "x-keys-not-described": [
            "nextScheduledSnapshot"
           ]
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_org_chart",
    "x-permissions-named": [
     "view_org_chart"
    ],
    "x-catalogue-group": "users"
   },
   "post": {
    "operationId": "post_user_management_org_snapshots",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Take an org-chart snapshot.",
    "description": "Request, as the catalogue words it: An OrgSnapshotCreate: { name: string } required\n\nReply, as the catalogue words it: 201 created OrgSnapshot | 400 Snapshot name is required\n\nGuard: 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\n\nAuth: Session + manage_org_chart",
    "requestBody": {
     "description": "An OrgSnapshotCreate: { name: string } required",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/OrgSnapshotCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "created OrgSnapshot"
     },
     "400": {
      "description": "Snapshot name is required"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_org_chart",
    "x-permissions-named": [
     "manage_org_chart"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/org-snapshots/diff": {
   "get": {
    "operationId": "get_user_management_org_snapshots_diff",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Diff two snapshots to show what changed.",
    "description": "Request, as the catalogue words it: Query: ?from= and ?to= (snapshot ids), both required\n\nReply, as the catalogue words it: 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'\n\nAuth: Session + view_org_chart",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "from": {
           "type": "object",
           "x-keys-not-described": [
            "id",
            "name",
            "date"
           ]
          },
          "to": {
           "type": "object",
           "x-keys-not-described": [
            "id",
            "name",
            "date"
           ]
          },
          "summary": {
           "type": "object",
           "x-keys-not-described": [
            "joined",
            "left",
            "moved"
           ]
          },
          "joined": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "id",
             "name",
             "department",
             "position"
            ]
           }
          },
          "moved": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "changes": {
              "type": "object",
              "x-keys-not-described": [
               "managerId",
               "department",
               "position"
              ]
             }
            },
            "x-keys-not-described": [
             "id",
             "name"
            ]
           }
          }
         },
         "x-keys-not-described": [
          "left"
         ]
        }
       }
      }
     },
     "400": {
      "description": "'from and to snapshot ids are required'"
     },
     "404": {
      "description": "'Snapshot not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_org_chart",
    "x-permissions-named": [
     "view_org_chart"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/org-snapshots/{id}": {
   "get": {
    "operationId": "get_user_management_org_snapshots_id",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Fetch one snapshot.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 the snapshot row including data[] (the captured org nodes, redacted for a caller not allowed to see personal data) | 404 'Snapshot not found'\n\nAuth: Session + view_org_chart",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: OrgSnapshot primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "the snapshot row including data[] (the captured org nodes, redacted for a caller not allowed to see personal data)"
     },
     "404": {
      "description": "'Snapshot not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_org_chart",
    "x-permissions-named": [
     "view_org_chart"
    ],
    "x-catalogue-group": "users"
   },
   "delete": {
    "operationId": "delete_user_management_org_snapshots_id",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Delete a snapshot.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 204 no content (destroy is idempotent — no 404 if id absent)\n\nGuard: safe to repeat: a second call still answers 204\n\nAuth: Session + manage_org_chart",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: OrgSnapshot primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "204": {
      "description": "no content (destroy is idempotent — no 404 if id absent)"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_org_chart",
    "x-permissions-named": [
     "manage_org_chart"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/license": {
   "get": {
    "operationId": "get_user_management_license",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Current license status, tier, and entitlements as last synced.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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 }, … }\n\nGuard: the provisioning key itself is never returned, only hasProvisioningKey\n\nAuth: Session + manage_security | view_dashboard | view_users",
    "responses": {
     "200": {
      "description": "{ 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 }, … }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "status": {
           "type": "string",
           "enum": [
            "ACTIVE",
            "PENDING_APPROVAL",
            "PROVISIONING_FAILED",
            "UNPROVISIONED"
           ]
          },
          "features": {
           "type": "object",
           "additionalProperties": {
            "type": "boolean"
           }
          },
          "heartbeatHealth": {
           "type": "object",
           "properties": {
            "state": {
             "type": "string",
             "enum": [
              "OK",
              "WARNING",
              "GRACE_EXPIRED"
             ]
            },
            "featuresDisabled": {
             "type": "boolean"
            }
           },
           "x-keys-not-described": [
            "consecutiveFailures",
            "firstFailureAt",
            "graceExpiresAt",
            "graceHoursRemaining",
            "policy"
           ]
          },
          "subscription": {
           "type": "object",
           "properties": {
            "state": {
             "type": "string",
             "enum": [
              "active",
              "grace",
              "lapsed",
              "unknown"
             ]
            }
           },
           "x-keys-not-described": [
            "enforced",
            "paidThroughAt",
            "graceEndsAt",
            "storeContactSinceExpiry",
            "premiumPaused"
           ]
          },
          "host": {
           "type": "object",
           "x-keys-not-described": [
            "cpuCount",
            "ramGb"
           ]
          }
         },
         "x-keys-not-described": [
          "licenseType",
          "companyName",
          "serviceId",
          "hasProvisioningKey",
          "lastHeartbeat",
          "nextHeartbeat",
          "heartbeatStatus"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security | view_dashboard | view_users",
    "x-permissions-named": [
     "manage_security",
     "view_dashboard",
     "view_users"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/license/provision": {
   "post": {
    "operationId": "post_user_management_license_provision",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Retired. Always answers 410; activation is automatic and store-managed.",
    "description": "Request, as the catalogue words it: Ignored.\n\nReply, as the catalogue words it: 410 { error:'Manual provisioning is disabled. This instance is activated automatically by the store.' }\n\nGuard: every call is audited as LICENSE_PROVISION_REJECTED\n\nAuth: Session + manage_security",
    "requestBody": {
     "description": "Ignored.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "410": {
      "description": "{ error:'Manual provisioning is disabled. This instance is activated automatically by the store.' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/license/sync": {
   "post": {
    "operationId": "post_user_management_license_sync",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Force an immediate license heartbeat instead of waiting for the 5-minute beat.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nGuard: a completed sync is audited as LICENSE_SYNCED; one that did not complete as LICENSE_SYNC_NOT_COMPLETED (warning), and entitlements are left unchanged\n\nAuth: Session + manage_security",
    "responses": {
     "200": {
      "description": "{ message:'Sync completed', config } | { message:'Sync did not complete', config:null } when the instance is not activated or the store did not answer or refused",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/license/reset": {
   "post": {
    "operationId": "post_user_management_license_reset",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Clear all license and provisioning state on the instance.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { message:'License reset successfully' } | 403 'Only an administrator can reset the licence.'\n\nGuard: 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\n\nAuth: Session + administrator role",
    "responses": {
     "200": {
      "description": "{ message:'License reset successfully' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "'Only an administrator can reset the licence.'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + administrator role",
    "x-permissions-named": [],
    "x-administrator-role": true,
    "x-catalogue-group": "users"
   }
  },
  "/user-management/saas-pricing/plans": {
   "get": {
    "operationId": "get_user_management_saas_pricing_plans",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Vendor pricing catalog entries.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 [ SaaSPlan ], ordered by vendor then price\n\nGuard: bare array, no paging\n\nAuth: Session + view_users | manage_users | manage_security",
    "responses": {
     "200": {
      "description": "[ SaaSPlan ], ordered by vendor then price",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_users | manage_users | manage_security",
    "x-permissions-named": [
     "manage_security",
     "manage_users",
     "view_users"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/saas-pricing/schedule": {
   "get": {
    "operationId": "get_user_management_saas_pricing_schedule",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Read the pricing-catalog sync schedule.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 the stored schedule object ({ frequency, cron } as saved by the PUT) | 404 'SaaS sync settings not configured'\n\nAuth: Session + manage_security",
    "responses": {
     "200": {
      "description": "the stored schedule object ({ frequency, cron } as saved by the PUT)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "'SaaS sync settings not configured'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-catalogue-group": "users"
   },
   "put": {
    "operationId": "put_user_management_saas_pricing_schedule",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Update the pricing-catalog sync schedule (cron).",
    "description": "Request, as the catalogue words it: { frequency: string, cron: string } both required\n\nReply, as the catalogue words it: 200 { message, schedule } | 400 Frequency and Cron expression are required | 404 SaaS sync settings not found\n\nAuth: Session + manage_security",
    "requestBody": {
     "description": "{ frequency: string, cron: string } both required",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "frequency": {
          "type": "string"
         },
         "cron": {
          "type": "string"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ message, schedule }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "Frequency and Cron expression are required"
     },
     "404": {
      "description": "SaaS sync settings not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/saas-pricing/sync": {
   "post": {
    "operationId": "post_user_management_saas_pricing_sync",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Run a pricing-catalog sync now.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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.\n\nAuth: Session + manage_security",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/device-catalog/models": {
   "get": {
    "operationId": "get_user_management_device_catalog_models",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Available hardware models for onboarding selection.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 [ DeviceModel ], ordered by brand then modelName\n\nGuard: bare array, no paging\n\nAuth: Session + view_users | manage_users | manage_security",
    "responses": {
     "200": {
      "description": "[ DeviceModel ], ordered by brand then modelName",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_users | manage_users | manage_security",
    "x-permissions-named": [
     "manage_security",
     "manage_users",
     "view_users"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/device-catalog/schedule": {
   "get": {
    "operationId": "get_user_management_device_catalog_schedule",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Read the device-catalog sync schedule.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 the stored schedule object ({ frequency, cron } as saved by the PUT) | 404 'Device catalog sync settings not configured'\n\nAuth: Session + manage_security",
    "responses": {
     "200": {
      "description": "the stored schedule object ({ frequency, cron } as saved by the PUT)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "'Device catalog sync settings not configured'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-catalogue-group": "users"
   },
   "put": {
    "operationId": "put_user_management_device_catalog_schedule",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Update the device-catalog sync schedule (cron).",
    "description": "Request, as the catalogue words it: { frequency: string, cron: string } both required\n\nReply, as the catalogue words it: 200 { message, schedule } | 400 required fields / Invalid cron expression | 404 Device catalog sync settings not found\n\nGuard: validate-then-swap: rejects invalid cron BEFORE persisting, then re-inits scheduler\n\nAuth: Session + manage_security",
    "requestBody": {
     "description": "{ frequency: string, cron: string } both required",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "frequency": {
          "type": "string"
         },
         "cron": {
          "type": "string"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ message, schedule }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "required fields / Invalid cron expression"
     },
     "404": {
      "description": "Device catalog sync settings not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/device-catalog/sync": {
   "post": {
    "operationId": "post_user_management_device_catalog_sync",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Run a device-catalog sync now.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nAuth: Session + manage_security",
    "responses": {
     "200": {
      "description": "{ synced:true, message:'Device catalog synchronized successfully', details } only when a feed actually synced",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "synced": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "message",
          "details"
         ]
        }
       }
      }
     },
     "409": {
      "description": "{ error, synced:false, reason:'no_feed', details } while no hardware catalog feed is connected (the current state; models and costs are left unchanged)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/notifications": {
   "get": {
    "operationId": "get_user_management_notifications",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: Query: ?unread=1 optional.\n\nReply, as the catalogue words it: 200 [ Notification ] (typed under Response Objects) — a bare array, no total\n\nGuard: only the caller's own entries are ever returned; there is no parameter that reads another user's feed\n\nAuth: Session + view_dashboard",
    "responses": {
     "200": {
      "description": "[ Notification ] (typed under Response Objects) — a bare array, no total",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/Notification"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_dashboard",
    "x-permissions-named": [
     "view_dashboard"
    ],
    "x-typed-objects": [
     "Notification"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/notifications/{id}/read": {
   "post": {
    "operationId": "post_user_management_notifications_id_read",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nAuth: Session + view_dashboard",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Notification primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ updated:integer } — 1 when this call marked it, 0 when it was already read or is not yours",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "updated": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "{ error:'Invalid notification id.' } when :id is not a positive whole number",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_dashboard",
    "x-permissions-named": [
     "view_dashboard"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/notifications/read-all": {
   "post": {
    "operationId": "post_user_management_notifications_read_all",
    "tags": [
     "Users, Roles, Groups & Permissions"
    ],
    "summary": "Mark every unread notification of yours as read. Safe to repeat: a second call answers updated: 0.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { updated:integer } — how many entries this call marked\n\nAuth: Session + view_dashboard",
    "responses": {
     "200": {
      "description": "{ updated:integer } — how many entries this call marked",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "updated": {
           "type": "integer"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_dashboard",
    "x-permissions-named": [
     "view_dashboard"
    ],
    "x-catalogue-group": "users"
   }
  },
  "/user-management/workflows": {
   "get": {
    "operationId": "get_user_management_workflows",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "List workflow definitions and versions.",
    "description": "Request, as the catalogue words it: Query: ?includeArchived=true also returns soft-deleted (archived) versions\n\nReply, as the catalogue words it: 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)\n\nGuard: bare array, no paging\n\nAuth: Session + manage_workflows | approve_workflows | execute_workflows | manage_approvals",
    "responses": {
     "200": {
      "description": "[ 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)",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/Workflow"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_workflows | approve_workflows | execute_workflows | manage_approvals",
    "x-permissions-named": [
     "approve_workflows",
     "execute_workflows",
     "manage_approvals",
     "manage_workflows"
    ],
    "x-typed-objects": [
     "Workflow"
    ],
    "x-catalogue-group": "workflow"
   },
   "post": {
    "operationId": "post_user_management_workflows",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Create a workflow definition (a new version of a named pipeline).",
    "description": "Request, as the catalogue words it: 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').\n\nReply, as the catalogue words it: 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.\n\nGuard: mass-assignment guard: only the fields above are picked; status clamped to draft|pending (approved/rejected unreachable here).\n\nAuth: Session + manage_workflows",
    "requestBody": {
     "description": "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').",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/WorkflowWrite"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "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.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Workflow"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_workflows",
    "x-permissions-named": [
     "manage_workflows"
    ],
    "x-typed-objects": [
     "Workflow"
    ],
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/workflows/{id}": {
   "put": {
    "operationId": "put_user_management_workflows_id",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Update a draft version. Status is not writable here.",
    "description": "Request, as the catalogue words it: Client-writable only: type, description, nodes[], edges[], automationMode, personaOverrides, status ('draft'|'pending') — the WorkflowWrite keys without name. status not settable to approved/rejected here.\n\nReply, as the catalogue words it: 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.\n\nGuard: manage_workflows + sudo. Same field whitelist as create; approved versions immutable in place (edits fork).\n\nAuth: Session + manage_workflows + sudo",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Workflow primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "Client-writable only: type, description, nodes[], edges[], automationMode, personaOverrides, status ('draft'|'pending') — the WorkflowWrite keys without name. status not settable to approved/rejected here.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "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.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Workflow"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_workflows + sudo",
    "x-permissions-named": [
     "manage_workflows"
    ],
    "x-step-up": "required",
    "x-typed-objects": [
     "Workflow"
    ],
    "x-catalogue-group": "workflow"
   },
   "delete": {
    "operationId": "delete_user_management_workflows_id",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Delete a workflow.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 204 no content (soft-delete/paranoid: sets deletedAt, retained for audit). 400 if version isCurrent (promote another first). 404 if not found.\n\nGuard: manage_workflows + sudo. Refuses to archive the live (isCurrent) version.\n\nAuth: Session + manage_workflows + sudo",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Workflow primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "204": {
      "description": "no content (soft-delete/paranoid: sets deletedAt, retained for audit). 400 if version isCurrent (promote another first). 404 if not found."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_workflows + sudo",
    "x-permissions-named": [
     "manage_workflows"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/workflows/{id}/promote": {
   "post": {
    "operationId": "post_user_management_workflows_id_promote",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Approve a pending version and put it live.",
    "description": "Request, as the catalogue words it: No body. Approver identity read from x-user header.\n\nReply, as the catalogue words it: 200 promoted Workflow (isCurrent/approved/isActive, approvedBy/At stamped; prior versions soft-archived). 403 no approver / SoD; 400 if definition invalid; 404 not found.\n\nGuard: 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.\n\nAuth: Session + approve_workflows + sudo",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Workflow primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "promoted Workflow (isCurrent/approved/isActive, approvedBy/At stamped; prior versions soft-archived). 403 no approver / SoD; 400 if definition invalid; 404 not found.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Workflow"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_workflows + sudo",
    "x-permissions-named": [
     "approve_workflows"
    ],
    "x-step-up": "required",
    "x-typed-objects": [
     "Workflow"
    ],
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/workflows/{id}/reject": {
   "post": {
    "operationId": "post_user_management_workflows_id_reject",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Reject a version awaiting approval.",
    "description": "Request, as the catalogue words it: A WorkflowReject: { reason } required (non-empty string).\n\nReply, as the catalogue words it: 200 rejected Workflow (status 'rejected', rejectionReason stored, isCurrent/isActive false). 400 if reason missing or version already approved; 404 not found.\n\nGuard: approve_workflows + sudo. Reason mandatory; cannot reject an already-approved version.\n\nAuth: Session + approve_workflows + sudo",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Workflow primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A WorkflowReject: { reason } required (non-empty string).",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/WorkflowReject"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "rejected Workflow (status 'rejected', rejectionReason stored, isCurrent/isActive false). 400 if reason missing or version already approved; 404 not found.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Workflow"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_workflows + sudo",
    "x-permissions-named": [
     "approve_workflows"
    ],
    "x-step-up": "required",
    "x-typed-objects": [
     "Workflow"
    ],
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/workflows/{id}/dry-run": {
   "post": {
    "operationId": "post_user_management_workflows_id_dry_run",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Validate a definition with no side effects: graph integrity, per-node readiness, provider configuration.",
    "description": "Request, as the catalogue words it: No body. Validates the stored definition of :id.\n\nReply, as the catalogue words it: 200 { workflow:{id,name,version}, valid:bool, errors[], warnings[], nodes[{id,label,provider,status,messages}] }. 404 if not found. No side effects.\n\nGuard: manage_workflows. Pure validation (graph integrity + per-node provider readiness); executes nothing.\n\nAuth: Session + manage_workflows",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Workflow primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ workflow:{id,name,version}, valid:bool, errors[], warnings[], nodes[{id,label,provider,status,messages}] }. 404 if not found. No side effects.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "workflow": {
           "type": "object",
           "x-keys-not-described": [
            "id",
            "name",
            "version"
           ]
          },
          "errors": {
           "type": "array"
          },
          "warnings": {
           "type": "array"
          }
         },
         "x-keys-not-described": [
          "valid",
          "nodes"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_workflows",
    "x-permissions-named": [
     "manage_workflows"
    ],
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/workflows/{id}/export": {
   "get": {
    "operationId": "get_user_management_workflows_id_export",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Export a workflow as portable JSON.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { zonitixWorkflow:1, name, type, version, description, automationMode, personaOverrides, nodes[], edges[], exportedAt } — the shape POST /user-management/workflows/import accepts | 404 'Workflow not found'\n\nAuth: Session + manage_workflows",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Workflow primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ zonitixWorkflow:1, name, type, version, description, automationMode, personaOverrides, nodes[], edges[], exportedAt } — the shape POST /user-management/workflows/import accepts",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "nodes": {
           "type": "array"
          },
          "edges": {
           "type": "array"
          }
         },
         "x-keys-not-described": [
          "zonitixWorkflow",
          "name",
          "type",
          "version",
          "description",
          "automationMode",
          "personaOverrides",
          "exportedAt"
         ]
        }
       }
      }
     },
     "404": {
      "description": "'Workflow not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_workflows",
    "x-permissions-named": [
     "manage_workflows"
    ],
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/workflows/import": {
   "post": {
    "operationId": "post_user_management_workflows_import",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Import a JSON definition as a new draft version.",
    "description": "Request, as the catalogue words it: { type ('onboarding'|'offboarding'), nodes[] (non-empty) } required; edges[], name (trimmed, max 120 chars), description, automationMode, personaOverrides optional. status forced to 'draft'.\n\nReply, as the catalogue words it: 201 Workflow created as a new DRAFT version (never auto-activated). 400 if payload invalid / type wrong / nodes empty / edges not array.\n\nGuard: 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.\n\nAuth: Session + manage_workflows + sudo",
    "requestBody": {
     "description": "{ type ('onboarding'|'offboarding'), nodes[] (non-empty) } required; edges[], name (trimmed, max 120 chars), description, automationMode, personaOverrides optional. status forced to 'draft'.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "Workflow created as a new DRAFT version (never auto-activated). 400 if payload invalid / type wrong / nodes empty / edges not array.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Workflow"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_workflows + sudo",
    "x-permissions-named": [
     "manage_workflows"
    ],
    "x-step-up": "required",
    "x-typed-objects": [
     "Workflow"
    ],
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/workflows/bulk-decision": {
   "post": {
    "operationId": "post_user_management_workflows_bulk_decision",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Promote or reject several pending versions in one call.",
    "description": "Request, as the catalogue words it: A WorkflowBulkDecision: { ids:[] (1 to 200 workflow ids; duplicates are processed once), action:'promote'|'reject' } required; reason required (non-empty) when action is 'reject'\n\nReply, as the catalogue words it: 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\n\nGuard: same guards as the single approve/reject; separation of duties and validate-before-promote are enforced per version\n\nAuth: Session + approve_workflows + sudo",
    "requestBody": {
     "description": "A WorkflowBulkDecision: { ids:[] (1 to 200 workflow ids; duplicates are processed once), action:'promote'|'reject' } required; reason required (non-empty) when action is 'reject'",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/WorkflowBulkDecision"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ succeeded:[id], failed:[{ id, error }] } — one failure does not stop the rest",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "failed": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "id",
             "error"
            ]
           }
          }
         },
         "x-keys-not-described": [
          "succeeded"
         ]
        }
       }
      }
     },
     "400": {
      "description": "unknown action, empty ids, more than 200 ids, or reject without a reason"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_workflows + sudo",
    "x-permissions-named": [
     "approve_workflows"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/workflows/{id}/clone": {
   "post": {
    "operationId": "post_user_management_workflows_id_clone",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Copy an existing pipeline into a new named draft. It still has to pass approval before it runs.",
    "description": "Request, as the catalogue words it: A WorkflowClone: { name? } — optional name for the copy, trimmed to 120 characters; omitted, the copy is named '<source name> (copy)'\n\nReply, as the catalogue words it: 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)\n\nGuard: manage_workflows + sudo. Not repeatable with the same name: the second call is refused with 409 instead of creating a duplicate\n\nAuth: Session + manage_workflows + sudo",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Workflow primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A WorkflowClone: { name? } — optional name for the copy, trimmed to 120 characters; omitted, the copy is named '<source name> (copy)'",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/WorkflowClone"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "the Workflow row of the copy, status 'draft', as version 1 of a new pipeline",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Workflow"
        }
       }
      }
     },
     "404": {
      "description": "'Workflow not found'"
     },
     "409": {
      "description": "the name already belongs to a pipeline (live or archived)"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_workflows + sudo",
    "x-permissions-named": [
     "manage_workflows"
    ],
    "x-step-up": "required",
    "x-typed-objects": [
     "Workflow"
    ],
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/workflows/{id}/retire": {
   "post": {
    "operationId": "post_user_management_workflows_id_retire",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Take a whole named pipeline out of service, or bring it back.",
    "description": "Request, as the catalogue words it: A WorkflowRetire: { retired:boolean } — true retires, anything else reinstates\n\nReply, as the catalogue words it: 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\n\nGuard: 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)\n\nAuth: Session + manage_workflows + sudo",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Workflow primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A WorkflowRetire: { retired:boolean } — true retires, anything else reinstates",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/WorkflowRetire"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ name, type, isActive, versionsChanged }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "the pipeline is the default for its type, or it is the only active pipeline of its type"
     },
     "404": {
      "description": "'Workflow not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_workflows + sudo",
    "x-permissions-named": [
     "manage_workflows"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/workflows/{id}/default": {
   "post": {
    "operationId": "post_user_management_workflows_id_default",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Choose which pipeline every unnamed run uses.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nGuard: safe to repeat; audited\n\nAuth: Session + manage_workflows",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Workflow primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "the Workflow row, now isDefault:true (the flag is cleared on every other pipeline of the same type)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Workflow"
        }
       }
      }
     },
     "400": {
      "description": "the version is not the live approved one, or the pipeline is not active"
     },
     "404": {
      "description": "'Workflow not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_workflows",
    "x-permissions-named": [
     "manage_workflows"
    ],
    "x-typed-objects": [
     "Workflow"
    ],
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/workflows/{id}/purge": {
   "delete": {
    "operationId": "delete_user_management_workflows_id_purge",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Permanently delete a never-used draft pipeline (hard delete, unlike the soft archive above).",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nGuard: refused for anything approved, default, or ever run; a second call answers 404 (the rows are gone)\n\nAuth: Session + manage_workflows + sudo",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Workflow primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ name, type, versionsPurged } — every version of that pipeline name is hard-deleted",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "the pipeline is the default, has an approved version, or has been used by a run"
     },
     "404": {
      "description": "'Workflow not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_workflows + sudo",
    "x-permissions-named": [
     "manage_workflows"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/tasks": {
   "get": {
    "operationId": "get_user_management_tasks",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "List workflow runs / task queue.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + execute_workflows | manage_approvals | manage_workflows",
    "parameters": [
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 100,
       "minimum": 1,
       "maximum": 500
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "offset",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 0,
       "minimum": 0
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "status",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "One status or a comma-separated list; at most 10 are read."
     },
     {
      "name": "q",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "Free text. A longer value is cut to 100 characters."
     },
     {
      "name": "envelope",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "true"
       ]
      },
      "description": "Answer { rows, total } and not a bare array."
     }
    ],
    "responses": {
     "200": {
      "description": "[ 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",
      "content": {
       "application/json": {
        "schema": {
         "anyOf": [
          {
           "type": "array"
          },
          {
           "type": "object",
           "properties": {
            "rows": {
             "type": "array"
            },
            "total": {
             "type": "integer"
            }
           }
          }
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + execute_workflows | manage_approvals | manage_workflows",
    "x-permissions-named": [
     "execute_workflows",
     "manage_approvals",
     "manage_workflows"
    ],
    "x-paging-parameters": [
     "limit",
     "offset"
    ],
    "x-catalogue-group": "workflow"
   },
   "post": {
    "operationId": "post_user_management_tasks",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Start a workflow run.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: execute_workflows. createdBy is set by the server. Not deduplicated: each call starts a new run.\n\nAuth: Session + execute_workflows",
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/WorkflowRunCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "a WorkflowRun (the Task row)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/WorkflowRun"
        }
       }
      }
     },
     "400": {
      "description": "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": {
      "description": "the pipeline is rejected, retired, archived or not yet approved | the persona confers access the caller may not grant"
     },
     "404": {
      "description": "pipeline not found"
     },
     "422": {
      "description": "a leaver run on a pipeline with no revocation step that is not linked to an employee"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + execute_workflows",
    "x-permissions-named": [
     "execute_workflows"
    ],
    "x-typed-objects": [
     "WorkflowRun"
    ],
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/tasks/notifications": {
   "get": {
    "operationId": "get_user_management_tasks_notifications",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Bounded notification feed for the dashboard badge.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 [ Task ] — active runs only, newest first, at most 50, edges omitted\n\nGuard: scoped to the caller's queues unless they are an administrator or hold manage_workflows\n\nAuth: Session + execute_workflows | manage_approvals | manage_workflows",
    "responses": {
     "200": {
      "description": "[ Task ] — active runs only, newest first, at most 50, edges omitted",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + execute_workflows | manage_approvals | manage_workflows",
    "x-permissions-named": [
     "execute_workflows",
     "manage_approvals",
     "manage_workflows"
    ],
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/tasks/{id}": {
   "get": {
    "operationId": "get_user_management_tasks_id",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "One run by id. Managers and administrators see any run; others only a run in their own queue.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nAuth: Session + execute_workflows | manage_approvals | manage_workflows",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Task primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "a WorkflowRun (the Task row, typed under Response Objects: title, status, assignedTo, currentStep, data, nodes, edges, startDate, dueDate, …)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/WorkflowRun"
        }
       }
      }
     },
     "403": {
      "description": "'Access Denied' for a run outside the caller's queues"
     },
     "404": {
      "description": "'Task not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + execute_workflows | manage_approvals | manage_workflows",
    "x-permissions-named": [
     "execute_workflows",
     "manage_approvals",
     "manage_workflows"
    ],
    "x-typed-objects": [
     "WorkflowRun"
    ],
    "x-catalogue-group": "workflow"
   },
   "put": {
    "operationId": "put_user_management_tasks_id",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Update a run (status, assignment, data).",
    "description": "Request, as the catalogue words it: A WorkflowRunUpdate. Whitelisted only: status, assignedTo, data, currentStep. Other columns (workflowId, title, createdBy, id) ignored.\n\nReply, as the catalogue words it: 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.\n\nGuard: 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).\n\nAuth: Session + execute_workflows | manage_approvals",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Task primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A WorkflowRunUpdate. Whitelisted only: status, assignedTo, data, currentStep. Other columns (workflowId, title, createdBy, id) ignored.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/WorkflowRunUpdate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "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.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/WorkflowRun"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + execute_workflows | manage_approvals",
    "x-permissions-named": [
     "execute_workflows",
     "manage_approvals"
    ],
    "x-typed-objects": [
     "WorkflowRun"
    ],
    "x-catalogue-group": "workflow"
   },
   "delete": {
    "operationId": "delete_user_management_tasks_id",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Delete a run.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 204 no content (hard destroy of the Task row). Succeeds even if row already gone.\n\nGuard: manage_workflows + sudo.\n\nAuth: Session + manage_workflows + sudo",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Task primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "204": {
      "description": "no content (hard destroy of the Task row). Succeeds even if row already gone."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_workflows + sudo",
    "x-permissions-named": [
     "manage_workflows"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/tasks/{id}/claim": {
   "post": {
    "operationId": "post_user_management_tasks_id_claim",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Mark a step as being handled by you (audited; it does not change who may act).",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nGuard: safe to repeat: a later claim overwrites the stamp; audited as TASK_CLAIMED\n\nAuth: Session + execute_workflows | manage_approvals",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Task primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "the Task row; data gains <currentNodeId>_claimedBy and <currentNodeId>_claimedAt"
     },
     "403": {
      "description": "the run is not in the caller's queue, or no step is in progress"
     },
     "404": {
      "description": "'Task not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + execute_workflows | manage_approvals",
    "x-permissions-named": [
     "execute_workflows",
     "manage_approvals"
    ],
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/tasks/{id}/recall": {
   "post": {
    "operationId": "post_user_management_tasks_id_recall",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Send a run back to an earlier human step. Only allowed before provisioning has started.",
    "description": "Request, as the catalogue words it: A WorkflowRunRecall: { targetNodeId } required — a human step the run has already passed; reason optional\n\nReply, as the catalogue words it: 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\n\nGuard: not repeatable: once recalled, the target is the current step and a second call is refused with 403\n\nAuth: Session + execute_workflows | manage_approvals",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Task primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A WorkflowRunRecall: { targetNodeId } required — a human step the run has already passed; reason optional",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/WorkflowRunRecall"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ recalledTo, reopened (steps reopened), notified (people told their completed step was reopened) }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "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"
     },
     "404": {
      "description": "'Task not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + execute_workflows | manage_approvals",
    "x-permissions-named": [
     "execute_workflows",
     "manage_approvals"
    ],
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/tasks/{id}/execute-node": {
   "post": {
    "operationId": "post_user_management_tasks_id_execute_node",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Execute one step of a run. This fires real external provisioning.",
    "description": "Request, as the catalogue words it: A WorkflowNodeExecute: { nodeId, taskData } — ONLY these taken from body. action/provider/target derived server-side from the run's stored graph.\n\nReply, as the catalogue words it: 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.\n\nGuard: 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.\n\nAuth: Session + execute_workflows | manage_approvals",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Task primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A WorkflowNodeExecute: { nodeId, taskData } — ONLY these taken from body. action/provider/target derived server-side from the run's stored graph.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/WorkflowNodeExecute"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "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.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + execute_workflows | manage_approvals",
    "x-permissions-named": [
     "execute_workflows",
     "manage_approvals"
    ],
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/tasks/{id}/kickoff": {
   "post": {
    "operationId": "post_user_management_tasks_id_kickoff",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Run all currently-eligible automated steps for a run on demand.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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.\n\nGuard: execute_workflows|manage_approvals + queue-ownership (canActOnQueue on task.assignedTo). Delegates to advanceTask, attributed to caller; fires real provisioning for the steps it runs.\n\nAuth: Session + execute_workflows | manage_approvals",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Task primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + execute_workflows | manage_approvals",
    "x-permissions-named": [
     "execute_workflows",
     "manage_approvals"
    ],
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/tasks/{id}/schedule": {
   "put": {
    "operationId": "put_user_management_tasks_id_schedule",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Move a run's dates when the start date changes.",
    "description": "Request, as the catalogue words it: A WorkflowRunSchedule: { startDate } — a real YYYY-MM-DD date, or null to clear it\n\nReply, as the catalogue words it: 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'\n\nGuard: safe to repeat (sets the dates; steps already executed keep theirs); audited as TASK_RESCHEDULED\n\nAuth: Session + execute_workflows | manage_approvals | manage_workflows",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Task primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A WorkflowRunSchedule: { startDate } — a real YYYY-MM-DD date, or null to clear it",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/WorkflowRunSchedule"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ id, startDate, redated (outstanding dated steps moved) }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "startDate is not a real YYYY-MM-DD date"
     },
     "403": {
      "description": "not in the run's queue"
     },
     "404": {
      "description": "'Task not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + execute_workflows | manage_approvals | manage_workflows",
    "x-permissions-named": [
     "execute_workflows",
     "manage_approvals",
     "manage_workflows"
    ],
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/tasks/{id}/evidence": {
   "get": {
    "operationId": "get_user_management_tasks_id_evidence",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "The deprovisioning record for a run (read-only).",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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'\n\nAuth: Session + execute_workflows | manage_approvals | manage_workflows",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Task primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "the run's record, built from the run and the graph it was started with"
     },
     "403": {
      "description": "the caller cannot see this run"
     },
     "404": {
      "description": "'Task not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + execute_workflows | manage_approvals | manage_workflows",
    "x-permissions-named": [
     "execute_workflows",
     "manage_approvals",
     "manage_workflows"
    ],
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/tasks/{id}/reveal-credential": {
   "post": {
    "operationId": "post_user_management_tasks_id_reveal_credential",
    "tags": [
     "Workflows & Tasks"
    ],
    "summary": "Show a new hire's temporary password once; it is purged after the reveal.",
    "description": "Request, as the catalogue words it: A CredentialReveal: { nodeId } required — the step that created the account\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + execute_workflows | manage_approvals | manage_workflows",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Task primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A CredentialReveal: { nodeId } required — the step that created the account",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/CredentialReveal"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ password }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "'nodeId is required'"
     },
     "403": {
      "description": "not in the run's queue"
     },
     "404": {
      "description": "'Task not found'"
     },
     "410": {
      "description": "the password was already revealed, or it was delivered by email"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + execute_workflows | manage_approvals | manage_workflows",
    "x-permissions-named": [
     "execute_workflows",
     "manage_approvals",
     "manage_workflows"
    ],
    "x-catalogue-group": "workflow"
   }
  },
  "/user-management/providers": {
   "get": {
    "operationId": "get_user_management_providers",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "List configured provider hooks (secrets never returned).",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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)\n\nGuard: bare array, no paging or filter\n\nAuth: Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes",
    "responses": {
     "200": {
      "description": "[ 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)",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/HookProvider"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes",
    "x-permissions-named": [
     "approve_identity_changes",
     "execute_identity_writeback",
     "manage_alignment_policy",
     "manage_hooks",
     "manage_integrations"
    ],
    "x-typed-objects": [
     "HookProvider"
    ],
    "x-catalogue-group": "integration"
   },
   "post": {
    "operationId": "post_user_management_providers",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Create a provider hook.",
    "description": "Request, as the catalogue words it: { name, type, config, description?, isActive?, syncInterval?, canReadUsers?, canReadGroups?, canWriteUsers?, canWriteGroups?, canReadAllResources? } — only these are persisted\n\nReply, as the catalogue words it: 201 the HookProvider with configuredCredentials[] (secret ciphertext stripped)\n\nGuard: 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\n\nAuth: Session + manage_hooks | manage_integrations",
    "requestBody": {
     "description": "{ name, type, config, description?, isActive?, syncInterval?, canReadUsers?, canReadGroups?, canWriteUsers?, canWriteGroups?, canReadAllResources? } — only these are persisted",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "the HookProvider with configuredCredentials[] (secret ciphertext stripped)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HookProvider"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_hooks | manage_integrations",
    "x-permissions-named": [
     "manage_hooks",
     "manage_integrations"
    ],
    "x-typed-objects": [
     "HookProvider"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/providers/{id}": {
   "put": {
    "operationId": "put_user_management_providers_id",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Update a provider hook.",
    "description": "Request, as the catalogue words it: same allowlisted fields as create (all optional); config is MERGED into existing, not replaced\n\nReply, as the catalogue words it: 200 the HookProvider | 404 'Provider not found'\n\nGuard: allowlist blocks forging isVerified; changing a CONNECTION_FIELD auto-resets isVerified=false; omitted secret keys preserved so config edits don't wipe credentials\n\nAuth: Session + manage_hooks | manage_integrations",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "same allowlisted fields as create (all optional); config is MERGED into existing, not replaced",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "the HookProvider",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HookProvider"
        }
       }
      }
     },
     "404": {
      "description": "'Provider not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_hooks | manage_integrations",
    "x-permissions-named": [
     "manage_hooks",
     "manage_integrations"
    ],
    "x-typed-objects": [
     "HookProvider"
    ],
    "x-catalogue-group": "integration"
   },
   "delete": {
    "operationId": "delete_user_management_providers_id",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Delete a provider hook.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 204 empty | 404 'Provider not found'\n\nGuard: 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\n\nAuth: Session + manage_hooks | manage_integrations",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "204": {
      "description": "empty"
     },
     "404": {
      "description": "'Provider not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_hooks | manage_integrations",
    "x-permissions-named": [
     "manage_hooks",
     "manage_integrations"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/providers/{id}/test": {
   "post": {
    "operationId": "post_user_management_providers_id_test",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Test connectivity/credentials for a hook.",
    "description": "Request, as the catalogue words it: No body (id in path).\n\nReply, as the catalogue words it: 200 testProviderConnection result | 404 | error passed to next() (logs PROVIDER_TEST_FAILED)\n\nAuth: Session + manage_hooks | manage_integrations",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "testProviderConnection result"
     },
     "404": {
      "description": " | error passed to next() (logs PROVIDER_TEST_FAILED)"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_hooks | manage_integrations",
    "x-permissions-named": [
     "manage_hooks",
     "manage_integrations"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/providers/{id}/validate": {
   "post": {
    "operationId": "post_user_management_providers_id_validate",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Validate per-capability readiness of a hook.",
    "description": "Request, as the catalogue words it: No body (id in path).\n\nReply, as the catalogue words it: 200 { ...checks result, provider:safe(p) }; persists isVerified=canFinish + force-disables any failed capability | 404\n\nGuard: only forces capabilities OFF, never ON — an unchecked capability stays the operator's choice\n\nAuth: Session + manage_hooks | manage_integrations",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ ...checks result, provider:safe(p) }; persists isVerified=canFinish + force-disables any failed capability",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_hooks | manage_integrations",
    "x-permissions-named": [
     "manage_hooks",
     "manage_integrations"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/providers/{id}/sync": {
   "post": {
    "operationId": "post_user_management_providers_id_sync",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Run a directory sync for a hook now.",
    "description": "Request, as the catalogue words it: { syncType?='delta', isManual?=true, dryRun?=false } all optional\n\nReply, as the catalogue words it: 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\n\nAuth: Session + manage_hooks | manage_integrations",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "{ syncType?='delta', isManual?=true, dryRun?=false } all optional",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ message, skipped?, unsupported?, inFlight?, partial? }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "'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"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_hooks | manage_integrations",
    "x-permissions-named": [
     "manage_hooks",
     "manage_integrations"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/providers/{id}/logs": {
   "get": {
    "operationId": "get_user_management_providers_id_logs",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Sync history and failures for a hook.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 [ ProviderSyncLog ] (typed under Response Objects) — the 10 most recent for that hook, newest first\n\nGuard: fixed at 10 rows; there is no paging parameter\n\nAuth: Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "[ ProviderSyncLog ] (typed under Response Objects) — the 10 most recent for that hook, newest first",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/ProviderSyncLog"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes",
    "x-permissions-named": [
     "approve_identity_changes",
     "execute_identity_writeback",
     "manage_alignment_policy",
     "manage_hooks",
     "manage_integrations"
    ],
    "x-typed-objects": [
     "ProviderSyncLog"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/providers/{id}/evidence/{type}": {
   "get": {
    "operationId": "get_user_management_providers_id_evidence_type",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Pull compliance evidence directly from a connected hook.",
    "description": "Request, as the catalogue words it: Path: :type names the evidence set to pull; what is accepted depends on the hook's type\n\nReply, as the catalogue words it: 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\n\nAuth: Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes | manage_compliance",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     },
     {
      "name": "type",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "the evidence data as returned by the provider adapter"
     },
     "400": {
      "description": "'Evidence fetching not supported for provider type: …' (plain text) for a hook that is not Okta, Entra ID, AWS, Intune or Jamf"
     },
     "404": {
      "description": "'Provider not found' (plain text)"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes | manage_compliance",
    "x-permissions-named": [
     "approve_identity_changes",
     "execute_identity_writeback",
     "manage_alignment_policy",
     "manage_compliance",
     "manage_hooks",
     "manage_integrations"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/providers/calendar/freebusy": {
   "get": {
    "operationId": "get_user_management_providers_calendar_freebusy",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Calendar free/busy for interview scheduling (Google Workspace hook).",
    "description": "Request, as the catalogue words it: Query: ?emails= (comma-separated), ?from= and ?to= (dates; default now to now + 7 days)\n\nReply, as the catalogue words it: 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.'\n\nAuth: Session + schedule_interviews | conduct_interview | manage_recruitment",
    "responses": {
     "200": {
      "description": "{ configured:true, calendars:{ [email]: { busy:[{ start, end }] } } } | { configured:false } when no active Google Workspace hook exists | { configured:true, error } when the service account is not authorised for calendars",
      "content": {
       "application/json": {
        "schema": {
         "anyOf": [
          {
           "type": "object",
           "properties": {
            "configured": {
             "type": "boolean"
            },
            "calendars": {
             "type": "object",
             "additionalProperties": {
              "type": "object",
              "properties": {
               "busy": {
                "type": "array",
                "items": {
                 "type": "object",
                 "x-keys-not-described": [
                  "start",
                  "end"
                 ]
                }
               }
              }
             }
            }
           }
          },
          {
           "type": "object",
           "properties": {
            "configured": {
             "type": "boolean"
            }
           }
          },
          {
           "type": "object",
           "properties": {
            "configured": {
             "type": "boolean"
            }
           },
           "x-keys-not-described": [
            "error"
           ]
          }
         ]
        }
       }
      }
     },
     "400": {
      "description": "'from/to must be valid dates.'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + schedule_interviews | conduct_interview | manage_recruitment",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "schedule_interviews"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/providers/lifecycle-events": {
   "get": {
    "operationId": "get_user_management_providers_lifecycle_events",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "User-lifecycle change events across connected providers.",
    "description": "Request, as the catalogue words it: Query: ?since= (a date; ignored when it does not parse)\n\nReply, as the catalogue words it: 200 { events[], sources[] } | 200 { events:[], sources:[], error } when collection fails — the call does not answer 5xx for a provider error\n\nGuard: no paging parameters are read; ?since= is the only way to narrow the reply\n\nAuth: Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes",
    "responses": {
     "200": {
      "description": "{ events[], sources[] } | { events:[], sources:[], error } when collection fails — the call does not answer 5xx for a provider error",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes",
    "x-permissions-named": [
     "approve_identity_changes",
     "execute_identity_writeback",
     "manage_alignment_policy",
     "manage_hooks",
     "manage_integrations"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/providers/cloud-summary": {
   "get": {
    "operationId": "get_user_management_providers_cloud_summary",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Which cloud hooks (AWS / Azure / GCP) exist and are verified. No configuration is returned.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 [ { id, type, name, isVerified } ] — cloud hooks only, id ascending\n\nGuard: bare array, every cloud hook in one reply (no paging)\n\nAuth: Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes | view_cloud_infrastructure",
    "responses": {
     "200": {
      "description": "[ { id, type, name, isVerified } ] — cloud hooks only, id ascending",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes | view_cloud_infrastructure",
    "x-permissions-named": [
     "approve_identity_changes",
     "execute_identity_writeback",
     "manage_alignment_policy",
     "manage_hooks",
     "manage_integrations",
     "view_cloud_infrastructure"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/providers/compliance-summary": {
   "get": {
    "operationId": "get_user_management_providers_compliance_summary",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Which hooks are connected, for the compliance pages. No configuration is returned.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 [ { id:integer, type:string (for example OKTA, AWS), name:string, isActive:boolean, isVerified:boolean, lastSync:string (date-time)|null } ] — every hook, id ascending\n\nAuth: Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes | manage_compliance",
    "responses": {
     "200": {
      "description": "[ { id:integer, type:string (for example OKTA, AWS), name:string, isActive:boolean, isVerified:boolean, lastSync:string (date-time)|null } ] — every hook, id ascending",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "type": "object",
          "properties": {
           "id": {
            "type": "integer"
           },
           "type": {
            "type": "string",
            "description": "for example OKTA, AWS"
           },
           "name": {
            "type": "string"
           },
           "isActive": {
            "type": "boolean"
           },
           "isVerified": {
            "type": "boolean"
           },
           "lastSync": {
            "type": [
             "string",
             "null"
            ],
            "format": "date-time"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes | manage_compliance",
    "x-permissions-named": [
     "approve_identity_changes",
     "execute_identity_writeback",
     "manage_alignment_policy",
     "manage_compliance",
     "manage_hooks",
     "manage_integrations"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/providers/requestable-apps": {
   "get": {
    "operationId": "get_user_management_providers_requestable_apps",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Id, type and name of the active hooks, for the Access Requests catalogue.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 [ { id, type, name } ] — active hooks only, ordered by name\n\nGuard: bare array, every active hook in one reply (no paging)\n\nAuth: Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes | request_access | approve_access_request | manage_users",
    "responses": {
     "200": {
      "description": "[ { id, type, name } ] — active hooks only, ordered by name",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes | request_access | approve_access_request | manage_users",
    "x-permissions-named": [
     "approve_access_request",
     "approve_identity_changes",
     "execute_identity_writeback",
     "manage_alignment_policy",
     "manage_hooks",
     "manage_integrations",
     "manage_users",
     "request_access"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/align/users": {
   "get": {
    "operationId": "get_user_management_align_users",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Users as seen across connected sources.",
    "description": "Request, as the catalogue words it: Query: ?limit (default 50, max 200), ?offset (default 0). No filter or sort parameter: ordered by email\n\nReply, as the catalogue words it: 200 [ { id, firstName, lastName, email, department, position, status, source, sourceProviderId, externalId, attributes } ] — bare array, no total\n\nGuard: alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes\n\nAuth: Session + view permission",
    "parameters": [
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 50,
       "minimum": 1,
       "maximum": 200
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "offset",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 0,
       "minimum": 0
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     }
    ],
    "responses": {
     "200": {
      "description": "[ { id, firstName, lastName, email, department, position, status, source, sourceProviderId, externalId, attributes } ] — bare array, no total",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view permission",
    "x-permissions-named": [],
    "x-paging-parameters": [
     "limit",
     "offset"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/align/drift": {
   "get": {
    "operationId": "get_user_management_align_drift",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Scan for drift between your source of truth and connected directories.",
    "description": "Request, as the catalogue words it: Query: ?refresh=1 (or true) bypasses the 5-minute cache\n\nReply, as the catalogue words it: 200 { connected, totalUsers, comparable, inSync, inDrift, localOnly, errors, syncPct, driftPct, byUser, truncated, sourceLabels, unscannedSources, cached:boolean }\n\nGuard: 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\n\nAuth: Session + view permission",
    "responses": {
     "200": {
      "description": "{ connected, totalUsers, comparable, inSync, inDrift, localOnly, errors, syncPct, driftPct, byUser, truncated, sourceLabels, unscannedSources, cached:boolean }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "cached": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "connected",
          "totalUsers",
          "comparable",
          "inSync",
          "inDrift",
          "localOnly",
          "errors",
          "syncPct",
          "driftPct",
          "byUser",
          "truncated",
          "sourceLabels",
          "unscannedSources"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view permission",
    "x-permissions-named": [],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/align/drift/trend": {
   "get": {
    "operationId": "get_user_management_align_drift_trend",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Drift over time.",
    "description": "Request, as the catalogue words it: Query: ?limit (default 30, max 365) — the number of most recent snapshots\n\nReply, as the catalogue words it: 200 [ { date, syncPct, driftPct, inDrift, comparable } ], oldest first | 200 [] when the history cannot be read\n\nGuard: alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes\n\nAuth: Session + view permission",
    "parameters": [
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 30,
       "minimum": 1,
       "maximum": 365
      },
      "description": "The number of most recent snapshots. A value outside the stated range is moved to the nearest bound, not refused."
     }
    ],
    "responses": {
     "200": {
      "description": "[ { date, syncPct, driftPct, inDrift, comparable } ], oldest first | [] when the history cannot be read",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view permission",
    "x-permissions-named": [],
    "x-paging-parameters": [
     "limit"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/align/orphans": {
   "get": {
    "operationId": "get_user_management_align_orphans",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Accounts present in a directory with no matching source record.",
    "description": "Request, as the catalogue words it: Query: ?refresh=1 (or true) bypasses the 5-minute cache\n\nReply, as the catalogue words it: 200 { generatedAt, connected, byProvider, totals:{ orphans, missing, statusMismatch }, cached:boolean }\n\nGuard: 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\n\nAuth: Session + view permission",
    "responses": {
     "200": {
      "description": "{ generatedAt, connected, byProvider, totals:{ orphans, missing, statusMismatch }, cached:boolean }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "totals": {
           "type": "object",
           "x-keys-not-described": [
            "orphans",
            "missing",
            "statusMismatch"
           ]
          },
          "cached": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "generatedAt",
          "connected",
          "byProvider"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view permission",
    "x-permissions-named": [],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/align/compare": {
   "get": {
    "operationId": "get_user_management_align_compare",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Field-by-field comparison for one user across sources.",
    "description": "Request, as the catalogue words it: Query: ?email= required\n\nReply, as the catalogue words it: 200 { email, profiles, matrix, hasConflicts } — compared on the fields the alignment policy has enabled | 400 'Email parameter is required'\n\nGuard: alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes\n\nAuth: Session + view permission",
    "responses": {
     "200": {
      "description": "{ email, profiles, matrix, hasConflicts } — compared on the fields the alignment policy has enabled",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "'Email parameter is required'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view permission",
    "x-permissions-named": [],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/align/policy": {
   "get": {
    "operationId": "get_user_management_align_policy",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Read the alignment policy.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 the alignment policy object, in the shape PUT /user-management/align/policy accepts\n\nGuard: alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes\n\nAuth: Session + view permission",
    "responses": {
     "200": {
      "description": "the alignment policy object, in the shape PUT /user-management/align/policy accepts"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view permission",
    "x-permissions-named": [],
    "x-catalogue-group": "integration"
   },
   "put": {
    "operationId": "put_user_management_align_policy",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Update the alignment policy.",
    "description": "Request, as the catalogue words it: policy object (e.g. { attributes:[{field,enabled,...}], autoReconcile:{...} }) passed whole to savePolicy\n\nReply, as the catalogue words it: 200 saved policy\n\nGuard: requireLicenseSoft('alignSyncEnabled') → 403 only if store explicitly disabled; POLICY perm (manage_alignment_policy|manage_hooks)\n\nAuth: Session + policy permission",
    "requestBody": {
     "description": "policy object (e.g. { attributes:[{field,enabled,...}], autoReconcile:{...} }) passed whole to savePolicy",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "saved policy"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + policy permission",
    "x-permissions-named": [],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/align/source-file": {
   "get": {
    "operationId": "get_user_management_align_source_file",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Read the uploaded source-of-truth file.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { loaded:true, fields[], rowCount, uploadedAt, uploadedBy } | 200 { loaded:false } — metadata only, the rows are not returned\n\nGuard: alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes\n\nAuth: Session + view permission",
    "responses": {
     "200": {
      "description": "{ loaded:true, fields[], rowCount, uploadedAt, uploadedBy } | { loaded:false } — metadata only, the rows are not returned",
      "content": {
       "application/json": {
        "schema": {
         "anyOf": [
          {
           "type": "object",
           "properties": {
            "loaded": {
             "type": "boolean"
            },
            "fields": {
             "type": "array"
            }
           },
           "x-keys-not-described": [
            "rowCount",
            "uploadedAt",
            "uploadedBy"
           ]
          },
          {
           "type": "object",
           "properties": {
            "loaded": {
             "type": "boolean"
            }
           }
          }
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view permission",
    "x-permissions-named": [],
    "x-catalogue-group": "integration"
   },
   "post": {
    "operationId": "post_user_management_align_source_file",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Upload a source-of-truth file.",
    "description": "Request, as the catalogue words it: { csv } required — CSV text string\n\nReply, as the catalogue words it: 200 { loaded:true, rowCount, fields[] } | 400 if csv missing/non-string or parse fails\n\nGuard: alignSync soft-license gate; POLICY perm; uploader taken from x-user header\n\nAuth: Session + policy permission",
    "requestBody": {
     "description": "{ csv } required — CSV text string",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ loaded:true, rowCount, fields[] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "loaded": {
           "type": "boolean"
          },
          "fields": {
           "type": "array"
          }
         },
         "x-keys-not-described": [
          "rowCount"
         ]
        }
       }
      }
     },
     "400": {
      "description": "if csv missing/non-string or parse fails"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + policy permission",
    "x-permissions-named": [],
    "x-catalogue-group": "integration"
   },
   "delete": {
    "operationId": "delete_user_management_align_source_file",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Remove the source-of-truth file.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { loaded:false }\n\nGuard: alignSync soft-license gate; POLICY perm\n\nAuth: Session + policy permission",
    "responses": {
     "200": {
      "description": "{ loaded:false }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "loaded": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + policy permission",
    "x-permissions-named": [],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/align/sync": {
   "post": {
    "operationId": "post_user_management_align_sync",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Write back corrections for a single user.",
    "description": "Request, as the catalogue words it: { email, sourceOfTruth, targets:string[] } required; dryRun? optional\n\nReply, as the catalogue words it: 200 alignUserAttributes result (with verification on real apply) | 400 'Invalid input payload'; failed apply audited then re-thrown\n\nGuard: alignSync soft-license gate; WRITEBACK perm (execute_identity_writeback|manage_hooks); real apply re-reads targets to confirm writes\n\nAuth: Session + writeback permission",
    "requestBody": {
     "description": "{ email, sourceOfTruth, targets:string[] } required; dryRun? optional",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "targets": {
          "type": "array",
          "items": {
           "type": "string"
          }
         }
        },
        "x-keys-not-described": [
         "email",
         "sourceOfTruth"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "alignUserAttributes result (with verification on real apply)"
     },
     "400": {
      "description": "'Invalid input payload'; failed apply audited then re-thrown"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + writeback permission",
    "x-permissions-named": [],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/align/bulk-sync": {
   "post": {
    "operationId": "post_user_management_align_bulk_sync",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Write back corrections in bulk.",
    "description": "Request, as the catalogue words it: { emails:string[], sourceOfTruth, targets:string[] } required; dryRun?, reason?, override? optional\n\nReply, as the catalogue words it: 200 { summary, results[], dryRun } | 200 { pendingApproval:true, requestId } if approval required | 400 invalid | 409 blast_radius_exceeded\n\nGuard: 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\n\nAuth: Session + manage_hooks | approve_identity_changes",
    "requestBody": {
     "description": "{ emails:string[], sourceOfTruth, targets:string[] } required; dryRun?, reason?, override? optional",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "emails": {
          "type": "array",
          "items": {
           "type": "string"
          }
         },
         "targets": {
          "type": "array",
          "items": {
           "type": "string"
          }
         }
        },
        "x-keys-not-described": [
         "sourceOfTruth"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ summary, results[], dryRun } | { pendingApproval:true, requestId } if approval required",
      "content": {
       "application/json": {
        "schema": {
         "anyOf": [
          {
           "type": "object",
           "properties": {
            "results": {
             "type": "array"
            }
           },
           "x-keys-not-described": [
            "summary",
            "dryRun"
           ]
          },
          {
           "type": "object",
           "properties": {
            "pendingApproval": {
             "type": "boolean"
            }
           },
           "x-keys-not-described": [
            "requestId"
           ]
          }
         ]
        }
       }
      }
     },
     "400": {
      "description": "invalid"
     },
     "409": {
      "description": "blast_radius_exceeded"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_hooks | approve_identity_changes",
    "x-permissions-named": [
     "approve_identity_changes",
     "manage_hooks"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/align/history": {
   "get": {
    "operationId": "get_user_management_align_history",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "History of write-back operations.",
    "description": "Request, as the catalogue words it: Query: ?limit (default 500, max 1000), ?offset (default 0). No filter parameter: newest first\n\nReply, as the catalogue words it: 200 [ IdentityAlignmentLog ] (typed under Response Objects) with source labels resolved — bare array, no total\n\nGuard: alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes\n\nAuth: Session + view permission",
    "parameters": [
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 500,
       "minimum": 1,
       "maximum": 1000
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "offset",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 0,
       "minimum": 0
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     }
    ],
    "responses": {
     "200": {
      "description": "[ IdentityAlignmentLog ] (typed under Response Objects) with source labels resolved — bare array, no total",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/IdentityAlignmentLog"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view permission",
    "x-permissions-named": [],
    "x-paging-parameters": [
     "limit",
     "offset"
    ],
    "x-typed-objects": [
     "IdentityAlignmentLog"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/align/rollback/{id}": {
   "post": {
    "operationId": "post_user_management_align_rollback_id",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Roll back a previous write-back.",
    "description": "Request, as the catalogue words it: No body (log id in path).\n\nReply, as the catalogue words it: 200 executeRollback result | 400 'Log ID is required'\n\nGuard: alignSync soft-license gate; WRITEBACK perm; adminUser from x-user header\n\nAuth: Session + writeback permission",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: IdentityAlignmentLog primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "executeRollback result"
     },
     "400": {
      "description": "'Log ID is required'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + writeback permission",
    "x-permissions-named": [],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/align/change-requests": {
   "get": {
    "operationId": "get_user_management_align_change_requests",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "List identity change requests awaiting approval.",
    "description": "Request, as the catalogue words it: Query: ?status= (exact match, for example pending); omitted, every status is returned\n\nReply, as the catalogue words it: 200 [ AlignmentChangeRequest ] (typed under Response Objects) with source labels resolved — newest first, capped at 200\n\nGuard: no paging parameter beyond the 200-row cap; when alignSync is switched off on the licence only held releases are listed\n\nAuth: Session + approve_identity_changes",
    "responses": {
     "200": {
      "description": "[ AlignmentChangeRequest ] (typed under Response Objects) with source labels resolved — newest first, capped at 200",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/AlignmentChangeRequest"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_identity_changes",
    "x-permissions-named": [
     "approve_identity_changes"
    ],
    "x-typed-objects": [
     "AlignmentChangeRequest"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/align/change-requests/{id}/approve": {
   "post": {
    "operationId": "post_user_management_align_change_requests_id_approve",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Approve a change request.",
    "description": "Request, as the catalogue words it: { comment? } optional\n\nReply, as the catalogue words it: 200 apply outcome (successCount, failed, targets, mismatches...) | 409 { error } on invalid state\n\nGuard: alignSync gate; approve_identity_changes only (separation of duties — approver need not be an operator)\n\nAuth: Session + approve_identity_changes",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: AlignmentChangeRequest primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "{ comment? } optional",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "apply outcome (successCount, failed, targets, mismatches...)"
     },
     "409": {
      "description": "{ error } on invalid state",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_identity_changes",
    "x-permissions-named": [
     "approve_identity_changes"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/align/change-requests/{id}/reject": {
   "post": {
    "operationId": "post_user_management_align_change_requests_id_reject",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Reject a change request.",
    "description": "Request, as the catalogue words it: { comment? } optional\n\nReply, as the catalogue words it: 200 updated request row | 409 { error } on invalid state\n\nGuard: alignSync gate; approve_identity_changes only (SoD)\n\nAuth: Session + approve_identity_changes",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: AlignmentChangeRequest primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "{ comment? } optional",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "updated request row"
     },
     "409": {
      "description": "{ error } on invalid state",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_identity_changes",
    "x-permissions-named": [
     "approve_identity_changes"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/discovery/summary": {
   "get": {
    "operationId": "get_user_management_discovery_summary",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { appsDiscovered, shadowItApps, dormantAccounts, activeUsers, sources } — degrades to an empty-but-valid shape rather than erroring\n\nGuard: alignSync soft-license gate (requireLicenseSoft('alignSyncEnabled') → 403 only on an explicit store disable); view_saas_discovery (a read-only right that implies view_dashboard)\n\nAuth: Session + view_saas_discovery",
    "responses": {
     "200": {
      "description": "{ appsDiscovered, shadowItApps, dormantAccounts, activeUsers, sources } — degrades to an empty-but-valid shape rather than erroring",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_saas_discovery",
    "x-permissions-named": [
     "view_saas_discovery"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/discovery/apps": {
   "get": {
    "operationId": "get_user_management_discovery_apps",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + view_saas_discovery",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "apps": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "status": {
              "type": "string",
              "enum": [
               "approved",
               "unmanaged"
              ]
             }
            },
            "x-keys-not-described": [
             "appKey",
             "appName",
             "category",
             "sources",
             "corroborated",
             "userCount",
             "activeUsers",
             "dormantAccounts",
             "lastActiveAt"
            ]
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_saas_discovery",
    "x-permissions-named": [
     "view_saas_discovery"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/user-management/discovery/apps/{appKey}/users": {
   "get": {
    "operationId": "get_user_management_discovery_apps_appKey_users",
    "tags": [
     "Identity Hooks & Alignment"
    ],
    "summary": "Per-person drilldown for one discovered app.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { appKey, users: [{ userId, userEmail, source, status, lastActiveAt }] }, most recently active first\n\nGuard: 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\n\nAuth: Session + view_saas_discovery",
    "parameters": [
     {
      "name": "appKey",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "{ appKey, users: [{ userId, userEmail, source, status, lastActiveAt }] }, most recently active first",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "users": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "userId",
             "userEmail",
             "source",
             "status",
             "lastActiveAt"
            ]
           }
          }
         },
         "x-keys-not-described": [
          "appKey"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_saas_discovery",
    "x-permissions-named": [
     "view_saas_discovery"
    ],
    "x-catalogue-group": "integration"
   }
  },
  "/compliance/metrics/overview": {
   "get": {
    "operationId": "get_compliance_metrics_overview",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Dashboard compliance overview.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { totalUsers, activeWorkflows, logStats:[{ level, count }], mfaActivations7, mfaActivations30, totalMfaEnabled, totalUsersForMfa, loginSuccess, loginFailure, authWindow, trendWindowDays:7, securityScore, uptimeScore, serviceStatuses[], loginTrends:[{ date, success, failure }] }\n\nGuard: 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)\n\nAuth: Session + view_dashboard",
    "responses": {
     "200": {
      "description": "{ totalUsers, activeWorkflows, logStats:[{ level, count }], mfaActivations7, mfaActivations30, totalMfaEnabled, totalUsersForMfa, loginSuccess, loginFailure, authWindow, trendWindowDays:7, securityScore, uptimeScore, serviceStatuses[], loginTrends:[{ date, success, failure }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "logStats": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "level",
             "count"
            ]
           }
          },
          "serviceStatuses": {
           "type": "array"
          },
          "loginTrends": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "date",
             "success",
             "failure"
            ]
           }
          }
         },
         "x-keys-not-described": [
          "totalUsers",
          "activeWorkflows",
          "mfaActivations7",
          "mfaActivations30",
          "totalMfaEnabled",
          "totalUsersForMfa",
          "loginSuccess",
          "loginFailure",
          "authWindow",
          "trendWindowDays",
          "securityScore",
          "uptimeScore"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_dashboard",
    "x-permissions-named": [
     "view_dashboard"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/metrics/tco": {
   "get": {
    "operationId": "get_compliance_metrics_tco",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Total-cost-of-ownership metrics.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { currentMonthlyBurn, totalAssetValue, projectedMonthlyAdd, projectedOneTimeAdd, projectedAnnualImpact, pipelineHeadcount, reclaimOpportunity, totalMonthlyWaste, optimizationAlerts[], personaDistribution:[{ name, userCount, monthlySpend }] }\n\nGuard: answers 403 when the Finance module is explicitly switched off on the licence\n\nAuth: Session + view_finance",
    "responses": {
     "200": {
      "description": "{ currentMonthlyBurn, totalAssetValue, projectedMonthlyAdd, projectedOneTimeAdd, projectedAnnualImpact, pipelineHeadcount, reclaimOpportunity, totalMonthlyWaste, optimizationAlerts[], personaDistribution:[{ name, userCount, monthlySpend }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "optimizationAlerts": {
           "type": "array"
          },
          "personaDistribution": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "name",
             "userCount",
             "monthlySpend"
            ]
           }
          }
         },
         "x-keys-not-described": [
          "currentMonthlyBurn",
          "totalAssetValue",
          "projectedMonthlyAdd",
          "projectedOneTimeAdd",
          "projectedAnnualImpact",
          "pipelineHeadcount",
          "reclaimOpportunity",
          "totalMonthlyWaste"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance",
    "x-permissions-named": [
     "view_finance"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/metrics/recent-critical-logs": {
   "get": {
    "operationId": "get_compliance_metrics_recent_critical_logs",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Most recent critical log events.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 [ AuditLog ] — the 5 most recent entries at a critical level, newest first\n\nAuth: Session + view_logs",
    "responses": {
     "200": {
      "description": "[ AuditLog ] — the 5 most recent entries at a critical level, newest first",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/AuditLog"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_logs",
    "x-permissions-named": [
     "view_logs"
    ],
    "x-typed-objects": [
     "AuditLog"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/log-stats": {
   "get": {
    "operationId": "get_compliance_reports_log_stats",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Log volume/severity statistics.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { levelDist:[{ level, count }], actionDist:[{ action, count }] (the 10 most frequent actions) } over the statistics window\n\nAuth: Session + view_logs | view_dashboard",
    "responses": {
     "200": {
      "description": "{ levelDist:[{ level, count }], actionDist:[{ action, count }] (the 10 most frequent actions) } over the statistics window",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "levelDist": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "level",
             "count"
            ]
           }
          },
          "actionDist": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "action",
             "count"
            ]
           },
           "description": "the 10 most frequent actions"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_logs | view_dashboard",
    "x-permissions-named": [
     "view_dashboard",
     "view_logs"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/stats": {
   "get": {
    "operationId": "get_compliance_reports_stats",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Headline reporting statistics.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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 }] }\n\nAuth: Session + view_dashboard | view_users",
    "responses": {
     "200": {
      "description": "{ 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 }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "devicesByType": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "type",
             "count"
            ]
           }
          },
          "usersByGroup": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "name",
             "userCount"
            ]
           }
          },
          "userStatusDist": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "status",
             "count"
            ]
           }
          },
          "userSourceDist": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "source",
             "count"
            ]
           }
          },
          "userSourceStatusDist": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "source",
             "status",
             "count"
            ]
           }
          },
          "workflowStatusDist": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "status",
             "count"
            ]
           }
          },
          "taskStatusDist": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "status",
             "count"
            ]
           }
          },
          "providerHealth": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "name",
             "type",
             "isVerified",
             "isActive",
             "lastSync",
             "syncCount"
            ]
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_dashboard | view_users",
    "x-permissions-named": [
     "view_dashboard",
     "view_users"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/assessments": {
   "post": {
    "operationId": "post_compliance_reports_compliance_assessments",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Record a compliance assessment for a framework. The server computes it; nothing you post is scored.",
    "description": "Request, as the catalogue words it: { framework } required — a framework the instance knows. Any readinessScore / counts / findings in the body are ignored.\n\nReply, as the catalogue words it: 201 { id, createdAt, readinessScore, serverComputed:true, passCount, failCount, manualCount, naCount, total } | 400 unknown framework | 503 'Could not compute … — nothing was recorded'\n\nGuard: 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\n\nAuth: Session + view_reporting",
    "requestBody": {
     "description": "{ framework } required — a framework the instance knows. Any readinessScore / counts / findings in the body are ignored.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ id, createdAt, readinessScore, serverComputed:true, passCount, failCount, manualCount, naCount, total }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "serverComputed": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "id",
          "createdAt",
          "readinessScore",
          "passCount",
          "failCount",
          "manualCount",
          "naCount",
          "total"
         ]
        }
       }
      }
     },
     "400": {
      "description": "unknown framework"
     },
     "503": {
      "description": "'Could not compute … — nothing was recorded'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting",
    "x-permissions-named": [
     "view_reporting"
    ],
    "x-catalogue-group": "compliance"
   },
   "get": {
    "operationId": "get_compliance_reports_compliance_assessments",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "List past assessment runs.",
    "description": "Request, as the catalogue words it: Query: ?framework= (exact name), ?limit (default 50, max 200). No offset: newest first, automatic trend snapshots excluded\n\nReply, as the catalogue words it: 200 { history:[{ id, framework, readinessScore, passCount, failCount, manualCount, total, naCount, applicable, generatedBy, createdAt }] }\n\nAuth: Session + view_reporting | manage_compliance",
    "parameters": [
     {
      "name": "framework",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 50,
       "minimum": 1,
       "maximum": 200
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     }
    ],
    "responses": {
     "200": {
      "description": "{ history:[{ id, framework, readinessScore, passCount, failCount, manualCount, total, naCount, applicable, generatedBy, createdAt }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "history": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "id",
             "framework",
             "readinessScore",
             "passCount",
             "failCount",
             "manualCount",
             "total",
             "naCount",
             "applicable",
             "generatedBy",
             "createdAt"
            ]
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-paging-parameters": [
     "limit"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/assessments/{id}": {
   "get": {
    "operationId": "get_compliance_reports_compliance_assessments_id",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Drill into one assessment run.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { id, framework, readinessScore, passCount, failCount, manualCount, total, naCount, applicable, findings, generatedBy, createdAt } | 404 'Assessment not found'\n\nAuth: Session + view_reporting | manage_compliance",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ComplianceAssessment primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ id, framework, readinessScore, passCount, failCount, manualCount, total, naCount, applicable, findings, generatedBy, createdAt }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "'Assessment not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/trend": {
   "get": {
    "operationId": "get_compliance_reports_compliance_trend",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Readiness trend over time.",
    "description": "Request, as the catalogue words it: Query: ?framework= (exact name), ?limit (default 30, max 365) — the number of most recent automatic snapshots\n\nReply, as the catalogue words it: 200 { trend:[{ date, readinessScore, failCount, framework, total, applicable, verifiedScore }] }, oldest first\n\nAuth: Session + view_reporting | manage_compliance",
    "parameters": [
     {
      "name": "framework",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 30,
       "minimum": 1,
       "maximum": 365
      },
      "description": "The number of most recent automatic snapshots. A value outside the stated range is moved to the nearest bound, not refused."
     }
    ],
    "responses": {
     "200": {
      "description": "{ trend:[{ date, readinessScore, failCount, framework, total, applicable, verifiedScore }] }, oldest first",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "trend": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "date",
             "readinessScore",
             "failCount",
             "framework",
             "total",
             "applicable",
             "verifiedScore"
            ]
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-paging-parameters": [
     "limit"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/control-states": {
   "get": {
    "operationId": "get_compliance_reports_compliance_control_states",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Current state of each mapped control.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 [ ControlState ] ordered by controlId — fields include controlId, status, origin ('auto' | 'manual'), message, owner, dueDate, frameworks, sourceId, sourceType, signal, freshnessAt, freshnessTtlDays, submittedBy\n\nGuard: bare array, returns every control (no paging or filter)\n\nAuth: Session + view_reporting | manage_compliance",
    "responses": {
     "200": {
      "description": "[ ControlState ] ordered by controlId — fields include controlId, status, origin ('auto' | 'manual'), message, owner, dueDate, frameworks, sourceId, sourceType, signal, freshnessAt, freshnessTtlDays, submittedBy",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/control-states/refresh": {
   "post": {
    "operationId": "post_compliance_reports_compliance_control_states_refresh",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Re-evaluate automated control checks.",
    "description": "Request, as the catalogue words it: No body. Query: ?async=1 starts the collection in the background instead of waiting for it\n\nReply, as the catalogue words it: 200 { updated:int, states[], harvestOk, harvestError, report, failedCollectors:[{ id, label, error }] } | with ?async=1: 202 { running:true, joined:boolean, jobId }\n\nGuard: 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)\n\nAuth: Session + manage_compliance",
    "responses": {
     "200": {
      "description": "{ updated:int, states[], harvestOk, harvestError, report, failedCollectors:[{ id, label, error }] } | with ?async=1: 202 { running:true, joined:boolean, jobId }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/control-states/{controlId}": {
   "put": {
    "operationId": "put_compliance_reports_compliance_control_states_controlId",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Manually set a control state.",
    "description": "Request, as the catalogue words it: controlId in path; body patch may include status, owner, dueDate, message, frameworks — only those keys applied\n\nReply, as the catalogue words it: 200 { control state row } | 400 if controlId missing\n\nGuard: marks row origin='manual' so auto-rollup leaves it alone until cleared\n\nAuth: Session + manage_compliance",
    "parameters": [
     {
      "name": "controlId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Typed from the primary key of the record it names: the ControlState primary key is a string, the control's id in its framework.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "controlId in path; body patch may include status, owner, dueDate, message, frameworks — only those keys applied",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ control state row }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "if controlId missing"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/control-states/{controlId}/attest": {
   "post": {
    "operationId": "post_compliance_reports_compliance_control_states_controlId_attest",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Record a manual pass on a control, governed by the attestation policy. This replaces the legacy attestation write.",
    "description": "Request, as the catalogue words it: { framework } required; note — required when the attestation policy is note_required (the default)\n\nReply, as the catalogue words it: 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\n\nGuard: audited as COMPLIANCE_CONTROL_ATTESTED, or COMPLIANCE_CONTROL_ATTEST_OVERRODE_REVIEW (warning) when it overrides a review someone else submitted; refusals are audited too\n\nAuth: Session + manage_compliance",
    "parameters": [
     {
      "name": "controlId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Typed from the primary key of the record it names: the ControlState primary key is a string, the control's id in its framework.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "{ framework } required; note — required when the attestation policy is note_required (the default)",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "the ControlState row, now a manual Pass"
     },
     "400": {
      "description": "framework missing, or a note is required"
     },
     "403": {
      "description": "the policy is review_only"
     },
     "409": {
      "description": "{ message, reason:'AUTO_FAIL_NEEDS_EVIDENCE' } an automated check is failing and no evidence is attached | { message, reason:'changed' } the control changed since it was read",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/control-states/{controlId}/submit": {
   "post": {
    "operationId": "post_compliance_reports_compliance_control_states_controlId_submit",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Submit a control for review (the assignee side of the submit-then-review path).",
    "description": "Request, as the catalogue words it: { framework } required\n\nReply, as the catalogue words it: 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\n\nGuard: guarded by a state check, so a repeated submit is refused with 409 already_in_review rather than queued twice; reviewers are notified\n\nAuth: Session + view_assigned_compliance | manage_compliance",
    "parameters": [
     {
      "name": "controlId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Typed from the primary key of the record it names: the ControlState primary key is a string, the control's id in its framework.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "{ framework } required",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "the ControlState row, status in_review"
     },
     "400": {
      "description": "framework missing"
     },
     "403": {
      "description": "the control is not assigned to the caller (and the caller lacks manage_compliance)"
     },
     "409": {
      "description": "{ message, reason } with reason already_in_review, already_passing, not_applicable or changed | an automated check is failing and no evidence is attached",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_assigned_compliance | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_assigned_compliance"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/control-states/{controlId}/review": {
   "post": {
    "operationId": "post_compliance_reports_compliance_control_states_controlId_review",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Review a submitted control (the reviewer side; separation of duties applies).",
    "description": "Request, as the catalogue words it: { framework, decision:'approve'|'reopen' } required; reviewNotes required when decision is 'reopen'\n\nReply, as the catalogue words it: 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' }\n\nGuard: 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\n\nAuth: Session + manage_compliance",
    "parameters": [
     {
      "name": "controlId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Typed from the primary key of the record it names: the ControlState primary key is a string, the control's id in its framework.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "{ framework, decision:'approve'|'reopen' } required; reviewNotes required when decision is 'reopen'",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "decision": {
          "type": "string",
          "enum": [
           "approve",
           "reopen"
          ]
         }
        },
        "x-keys-not-described": [
         "framework"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the ControlState row"
     },
     "400": {
      "description": "a required field is missing or decision is not approve|reopen"
     },
     "403": {
      "description": "separation of duties: the caller submitted this control and is not an administrator (when the review policy requires a separate reviewer)"
     },
     "409": {
      "description": "the control was not submitted, or is not awaiting review | { message, reason:'changed' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/control-states/{controlId}/revoke-pass": {
   "post": {
    "operationId": "post_compliance_reports_compliance_control_states_controlId_revoke_pass",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Withdraw a manual pass.",
    "description": "Request, as the catalogue words it: { note } required (at most 1000 characters); framework optional (names the framework in the owner's notification)\n\nReply, as the catalogue words it: 200 the ControlState row | 400 note missing or too long | 409 { message, reason } with reason absent, not_manual, not_passing or changed\n\nGuard: only a manual pass can be revoked; a repeat is refused with 409 not_passing; audited at warning level and the owners are notified\n\nAuth: Session + manage_compliance",
    "parameters": [
     {
      "name": "controlId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Typed from the primary key of the record it names: the ControlState primary key is a string, the control's id in its framework.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "{ note } required (at most 1000 characters); framework optional (names the framework in the owner's notification)",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "the ControlState row"
     },
     "400": {
      "description": "note missing or too long"
     },
     "409": {
      "description": "{ message, reason } with reason absent, not_manual, not_passing or changed",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/control-states/{controlId}/return-to-automated": {
   "post": {
    "operationId": "post_compliance_reports_compliance_control_states_controlId_return_to_automated",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Hand a manually-set control back to the automated checks.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { returned:true } | 409 { message, reason } with reason in_review, already_automated, no_collector or changed\n\nGuard: a repeat is refused with 409 already_automated; audited\n\nAuth: Session + manage_compliance",
    "parameters": [
     {
      "name": "controlId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Typed from the primary key of the record it names: the ControlState primary key is a string, the control's id in its framework.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ returned:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "returned": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "409": {
      "description": "{ message, reason } with reason in_review, already_automated, no_collector or changed",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/attestation-policy": {
   "get": {
    "operationId": "get_compliance_reports_compliance_attestation_policy",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Read the policy that governs manual attestations.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { mode:'allow'|'note_required'|'review_only' } — note_required when nothing is saved\n\nAuth: Session + view_reporting | manage_compliance",
    "responses": {
     "200": {
      "description": "{ mode:'allow'|'note_required'|'review_only' } — note_required when nothing is saved",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "mode": {
           "type": "string",
           "enum": [
            "allow",
            "note_required",
            "review_only"
           ]
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-catalogue-group": "compliance"
   },
   "post": {
    "operationId": "post_compliance_reports_compliance_attestation_policy",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Change the attestation policy.",
    "description": "Request, as the catalogue words it: { mode:'allow'|'note_required'|'review_only' }\n\nReply, as the catalogue words it: 200 { mode } — the mode now in force\n\nGuard: an unknown mode is stored as note_required, not rejected; safe to repeat; audited as COMPLIANCE_ATTESTATION_POLICY_SET\n\nAuth: Session + manage_security",
    "requestBody": {
     "description": "{ mode:'allow'|'note_required'|'review_only' }",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "mode": {
          "type": "string",
          "enum": [
           "allow",
           "note_required",
           "review_only"
          ]
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ mode } — the mode now in force",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/attestations": {
   "get": {
    "operationId": "get_compliance_reports_compliance_attestations",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Read-only history of the legacy attestations. New manual verdicts are recorded on the control itself (see control-states/:controlId/attest).",
    "description": "Request, as the catalogue words it: Query: ?framework= (exact name)\n\nReply, as the catalogue words it: 200 { attestations:[{ framework, controlId, satisfied, note, attestedBy, attestedAt }] }\n\nGuard: returns every matching row (no paging)\n\nAuth: Session + view_reporting | manage_compliance",
    "responses": {
     "200": {
      "description": "{ attestations:[{ framework, controlId, satisfied, note, attestedBy, attestedAt }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "attestations": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "framework",
             "controlId",
             "satisfied",
             "note",
             "attestedBy",
             "attestedAt"
            ]
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-catalogue-group": "compliance"
   },
   "post": {
    "operationId": "post_compliance_reports_compliance_attestations",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Retired. Legacy attestation write — use control-states/:controlId/attest, or submit then review.",
    "description": "Request, as the catalogue words it: Ignored.\n\nReply, as the catalogue words it: 410 Gone { message, governedPath:{ attest, submit, review } } — nothing is written\n\nAuth: Session + manage_compliance",
    "requestBody": {
     "description": "Ignored.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "410": {
      "description": "Gone { message, governedPath:{ attest, submit, review } } — nothing is written",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "compliance"
   },
   "delete": {
    "operationId": "delete_compliance_reports_compliance_attestations",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Retired. Legacy attestation delete.",
    "description": "Request, as the catalogue words it: Ignored.\n\nReply, as the catalogue words it: 410 Gone { message, governedPath } — nothing is deleted\n\nAuth: Session + manage_compliance",
    "responses": {
     "410": {
      "description": "Gone { message, governedPath } — nothing is deleted",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/assignments": {
   "get": {
    "operationId": "get_compliance_reports_compliance_assignments",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "List control ownership assignments.",
    "description": "Request, as the catalogue words it: Query: ?framework= (exact name)\n\nReply, as the catalogue words it: 200 [ ComplianceAssignment ] ordered by domain — framework, domain, assigneeId, assigneeName, assigneeEmail, dueDate, note, assignedBy\n\nGuard: bare array, no paging\n\nAuth: Session + view_reporting | manage_compliance",
    "responses": {
     "200": {
      "description": "[ ComplianceAssignment ] ordered by domain — framework, domain, assigneeId, assigneeName, assigneeEmail, dueDate, note, assignedBy",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-catalogue-group": "compliance"
   },
   "post": {
    "operationId": "post_compliance_reports_compliance_assignments",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Assign a control to an owner with a deadline.",
    "description": "Request, as the catalogue words it: { framework, domain } required; assigneeId/assigneeName/assigneeEmail/dueDate/note optional\n\nReply, as the catalogue words it: 200 { assignment row } | 400 if framework or domain missing\n\nGuard: 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)\n\nAuth: Session + manage_compliance",
    "requestBody": {
     "description": "{ framework, domain } required; assigneeId/assigneeName/assigneeEmail/dueDate/note optional",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ assignment row }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "if framework or domain missing"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "compliance"
   },
   "delete": {
    "operationId": "delete_compliance_reports_compliance_assignments",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Remove an assignment.",
    "description": "Request, as the catalogue words it: No body — framework & domain passed as QUERY params (both required)\n\nReply, as the catalogue words it: 200 { ok:true } | 400 if framework or domain missing\n\nAuth: Session + manage_compliance",
    "responses": {
     "200": {
      "description": "{ ok:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "if framework or domain missing"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/my-assignments": {
   "get": {
    "operationId": "get_compliance_reports_compliance_my_assignments",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Controls assigned to you.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 [ ComplianceAssignment ] where the caller is the assignee (matched on user id or email), earliest dueDate first | 200 [] when the caller cannot be identified\n\nGuard: bare array, every assignment of the caller in one reply (no paging)\n\nAuth: Session + view_assigned_compliance | manage_compliance | view_reporting",
    "responses": {
     "200": {
      "description": "[ ComplianceAssignment ] where the caller is the assignee (matched on user id or email), earliest dueDate first | [] when the caller cannot be identified",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_assigned_compliance | manage_compliance | view_reporting",
    "x-permissions-named": [
     "manage_compliance",
     "view_assigned_compliance",
     "view_reporting"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/reminders/send": {
   "post": {
    "operationId": "post_compliance_reports_compliance_reminders_send",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Email deadline reminders to control owners.",
    "description": "Request, as the catalogue words it: No body (uses session for audit context).\n\nReply, as the catalogue words it: 200 { result } summarizing reminder emails sent to assignees\n\nGuard: email is best-effort/gated: nothing sends unless SMTP + recipients configured\n\nAuth: Session + manage_compliance",
    "responses": {
     "200": {
      "description": "{ result } summarizing reminder emails sent to assignees",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/remediations": {
   "get": {
    "operationId": "get_compliance_reports_compliance_remediations",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "List tracked remediations.",
    "description": "Request, as the catalogue words it: Query: ?framework= (exact name)\n\nReply, as the catalogue words it: 200 { remediations:[{ id, framework, controlId, title, owner, dueDate, status, createdBy, updatedAt }] }, earliest dueDate first\n\nGuard: returns every matching row (no paging)\n\nAuth: Session + view_reporting | manage_compliance",
    "responses": {
     "200": {
      "description": "{ remediations:[{ id, framework, controlId, title, owner, dueDate, status, createdBy, updatedAt }] }, earliest dueDate first",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "remediations": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "id",
             "framework",
             "controlId",
             "title",
             "owner",
             "dueDate",
             "status",
             "createdBy",
             "updatedAt"
            ]
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-catalogue-group": "compliance"
   },
   "post": {
    "operationId": "post_compliance_reports_compliance_remediations",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Create or update a remediation item.",
    "description": "Request, as the catalogue words it: { framework, controlId, title } required; owner/dueDate/status optional (status default 'open')\n\nReply, as the catalogue words it: 201 { id, framework, controlId, title, owner, dueDate, status } | 400 if framework/controlId/title missing\n\nGuard: upsert by framework+controlId; safe to repeat (the same framework and controlId update the existing row)\n\nAuth: Session + manage_compliance",
    "requestBody": {
     "description": "{ framework, controlId, title } required; owner/dueDate/status optional (status default 'open')",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ id, framework, controlId, title, owner, dueDate, status }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "if framework/controlId/title missing"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "compliance"
   },
   "delete": {
    "operationId": "delete_compliance_reports_compliance_remediations",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Delete a remediation item.",
    "description": "Request, as the catalogue words it: No body — framework & controlId passed as QUERY params\n\nReply, as the catalogue words it: 200 { ok:bool } (false if nothing matched)\n\nAuth: Session + manage_compliance",
    "responses": {
     "200": {
      "description": "{ ok:bool } (false if nothing matched)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/digest/config": {
   "get": {
    "operationId": "get_compliance_reports_compliance_digest_config",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Read the stored configuration of the retired compliance digest (read-only; the digest no longer sends).",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { enabled, recipients[], intervalHours, framework, lastSent, smtpConfigured, retired:true, replacement }\n\nAuth: Session + view_reporting | manage_compliance",
    "responses": {
     "200": {
      "description": "{ enabled, recipients[], intervalHours, framework, lastSent, smtpConfigured, retired:true, replacement }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "recipients": {
           "type": "array"
          },
          "retired": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "enabled",
          "intervalHours",
          "framework",
          "lastSent",
          "smtpConfigured",
          "replacement"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-catalogue-group": "compliance"
   },
   "post": {
    "operationId": "post_compliance_reports_compliance_digest_config",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Retired. The compliance digest was replaced by Scheduled reports (/compliance/reports/schedules).",
    "description": "Request, as the catalogue words it: Ignored.\n\nReply, as the catalogue words it: 410 Gone { error, code:'DIGEST_RETIRED', replacement:{ ui, api:'/reports/schedules' } } — nothing is saved; the call is audit-logged\n\nGuard: an enabled digest was carried into a scheduled report automatically when the digest was retired\n\nAuth: Session + manage_compliance",
    "requestBody": {
     "description": "Ignored.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "410": {
      "description": "Gone { error, code:'DIGEST_RETIRED', replacement:{ ui, api:'/reports/schedules' } } — nothing is saved; the call is audit-logged",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/digest/send": {
   "post": {
    "operationId": "post_compliance_reports_compliance_digest_send",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Retired. Use Scheduled reports to send a report now (/compliance/reports/schedules/:id/run).",
    "description": "Request, as the catalogue words it: Ignored.\n\nReply, as the catalogue words it: 410 Gone { error, code:'DIGEST_RETIRED', replacement } — nothing is sent; the call is audit-logged\n\nAuth: Session + manage_compliance",
    "requestBody": {
     "description": "Ignored.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "410": {
      "description": "Gone { error, code:'DIGEST_RETIRED', replacement } — nothing is sent; the call is audit-logged",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/compliance/audit": {
   "post": {
    "operationId": "post_compliance_reports_compliance_audit",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Write a report-engine action to the audit trail.",
    "description": "Request, as the catalogue words it: { event: 'EXPORT'|'REMEDIATION' } required (case-insensitive); detail (optional, truncated 300), framework optional\n\nReply, as the catalogue words it: 200 { ok:true } | 400 { error:'Unknown report audit event' }\n\nGuard: event whitelist: only EXPORT/REMEDIATION map to an audit action, anything else rejected\n\nAuth: Session + view_reporting",
    "requestBody": {
     "description": "{ event: 'EXPORT'|'REMEDIATION' } required (case-insensitive); detail (optional, truncated 300), framework optional",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "event": {
          "type": "string",
          "enum": [
           "EXPORT",
           "REMEDIATION"
          ]
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "{ error:'Unknown report audit event' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting",
    "x-permissions-named": [
     "view_reporting"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/system-health": {
   "get": {
    "operationId": "get_compliance_reports_system_health",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Live host load and container status (via a read-only Docker socket proxy).",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nGuard: the result is cached for 25 seconds, so polling faster returns the same reading\n\nAuth: Session + view_system",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "host": {
           "type": "object",
           "x-keys-not-described": [
            "cpuCores",
            "load1",
            "load5",
            "load15",
            "loadPct",
            "memTotal",
            "memUsed",
            "memPct",
            "swapTotal",
            "swapUsed",
            "swapPct",
            "disk"
           ]
          },
          "containers": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "name",
             "image",
             "state",
             "status",
             "cpuPct",
             "memBytes",
             "memPct",
             "unhealthy"
            ]
           }
          }
         },
         "x-keys-not-described": [
          "at",
          "dockerAvailable",
          "containerCount",
          "runningCount",
          "unhealthyCount"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_system",
    "x-permissions-named": [
     "view_system"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/patch-status": {
   "get": {
    "operationId": "get_compliance_reports_patch_status",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Application (git) update status.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { current:{ commit, tag, committedAt, deployedAt }, repo, branch, upToDate (true | false | null when the check itself failed), behind, securityBehind, updates[], checkedAt, source, … }\n\nAuth: Session + view_system",
    "responses": {
     "200": {
      "description": "{ current:{ commit, tag, committedAt, deployedAt }, repo, branch, upToDate (true | false | null when the check itself failed), behind, securityBehind, updates[], checkedAt, source, … }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "current": {
           "type": "object",
           "x-keys-not-described": [
            "commit",
            "tag",
            "committedAt",
            "deployedAt"
           ]
          },
          "updates": {
           "type": "array"
          }
         },
         "x-keys-not-described": [
          "repo",
          "branch",
          "upToDate",
          "behind",
          "securityBehind",
          "checkedAt",
          "source"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_system",
    "x-permissions-named": [
     "view_system"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/host-patches": {
   "get": {
    "operationId": "get_compliance_reports_host_patches",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Host OS patch status.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nAuth: Session + view_system",
    "responses": {
     "200": {
      "description": "{ available:true, os:string|null, manager:string, total:integer, security:integer, phased:integer, packages:array, refreshFailed:boolean, checkedAt:string|null } | { available:false, os:null, manager:'unknown', total:0, security:0, packages:[], checkedAt:null } when the host agent has not reported yet",
      "content": {
       "application/json": {
        "schema": {
         "anyOf": [
          {
           "type": "object",
           "properties": {
            "available": {
             "type": "boolean"
            },
            "os": {
             "type": [
              "string",
              "null"
             ]
            },
            "manager": {
             "type": "string"
            },
            "total": {
             "type": "integer"
            },
            "security": {
             "type": "integer"
            },
            "phased": {
             "type": "integer"
            },
            "packages": {
             "type": "array"
            },
            "refreshFailed": {
             "type": "boolean"
            },
            "checkedAt": {
             "type": [
              "string",
              "null"
             ]
            }
           }
          },
          {
           "type": "object",
           "properties": {
            "available": {
             "type": "boolean"
            }
           },
           "x-keys-not-described": [
            "os",
            "manager",
            "total",
            "security",
            "packages",
            "checkedAt"
           ]
          }
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_system",
    "x-permissions-named": [
     "view_system"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/update-status": {
   "get": {
    "operationId": "get_compliance_reports_update_status",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "State of any pending update request.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nAuth: Session + view_system",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "backupConfig": {
           "type": "object",
           "x-keys-not-described": [
            "offboxConfigured"
           ]
          }
         },
         "x-keys-not-described": [
          "status",
          "pending",
          "pendingApproval",
          "pendingTtlMs"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_system",
    "x-permissions-named": [
     "view_system"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/update-request": {
   "post": {
    "operationId": "post_compliance_reports_update_request",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Request a backup, patch, or OS update.",
    "description": "Request, as the catalogue words it: { mode } required — one of backup|security|full|os-security|os-full\n\nReply, as the catalogue words it: 202 { status:'dispatched'|'pending_approval', request } | 400 invalid mode | 409 if one already queued/awaiting approval\n\nGuard: 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\n\nAuth: Session + request_system_updates",
    "requestBody": {
     "description": "{ mode } required — one of backup|security|full|os-security|os-full",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "202": {
      "description": "{ status:'dispatched'|'pending_approval', request }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "status": {
           "type": "string",
           "enum": [
            "dispatched",
            "pending_approval"
           ]
          }
         },
         "x-keys-not-described": [
          "request"
         ]
        }
       }
      }
     },
     "400": {
      "description": "invalid mode"
     },
     "409": {
      "description": "if one already queued/awaiting approval"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + request_system_updates",
    "x-permissions-named": [
     "request_system_updates"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/update-approve": {
   "post": {
    "operationId": "post_compliance_reports_update_approve",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Approve a requested update so the host agent may run it.",
    "description": "Request, as the catalogue words it: No body (approver = session user).\n\nReply, as the catalogue words it: 200 { approved:true, request } | 404 if none awaiting | 409 if op already in progress\n\nGuard: 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\n\nAuth: Session + approve_system_updates",
    "responses": {
     "200": {
      "description": "{ approved:true, request }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "approved": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "request"
         ]
        }
       }
      }
     },
     "404": {
      "description": "if none awaiting"
     },
     "409": {
      "description": "if op already in progress"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_system_updates",
    "x-permissions-named": [
     "approve_system_updates"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/update-reject": {
   "post": {
    "operationId": "post_compliance_reports_update_reject",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Reject a requested update.",
    "description": "Request, as the catalogue words it: No body (rejecter = session user).\n\nReply, as the catalogue words it: 200 { rejected:true, request } | 404 if none awaiting approval\n\nGuard: a repeat answers 404 (nothing awaiting approval)\n\nAuth: Session + approve_system_updates",
    "responses": {
     "200": {
      "description": "{ rejected:true, request }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "rejected": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "request"
         ]
        }
       }
      }
     },
     "404": {
      "description": "if none awaiting approval"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_system_updates",
    "x-permissions-named": [
     "approve_system_updates"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/maintenance-status": {
   "get": {
    "operationId": "get_compliance_reports_maintenance_status",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "State of the app-wide maintenance notice.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { state:'none' } | { state:'scheduled', at } (a maintenance window starts within 60 minutes) | { state:'running', kind:'update'|'restore', rollingBack, scheduled }\n\nGuard: readable by every signed-in user; carries no release tag, host detail or requester; any internal failure reads as state 'none'\n\nAuth: Session",
    "responses": {
     "200": {
      "description": "{ state:'none' } | { state:'scheduled', at } (a maintenance window starts within 60 minutes) | { state:'running', kind:'update'|'restore', rollingBack, scheduled }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/auto-update-config": {
   "get": {
    "operationId": "get_compliance_reports_auto_update_config",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Read the automatic update schedule.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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 }\n\nGuard: 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\n\nAuth: Session + view_system | request_system_updates",
    "responses": {
     "200": {
      "description": "{ 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 }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "enabled": {
           "type": "boolean"
          },
          "day": {
           "type": "string",
           "enum": [
            "sun",
            "mon",
            "tue",
            "wed",
            "thu",
            "fri",
            "sat"
           ]
          },
          "time": {
           "type": "string",
           "description": "HH:MM"
          },
          "timezone": {
           "type": "string",
           "description": "IANA zone"
          },
          "timezoneSaved": {
           "type": "boolean"
          },
          "osSecurity": {
           "type": "boolean"
          },
          "osFull": {
           "type": "boolean"
          },
          "maxMinutes": {
           "type": "integer"
          },
          "appSecurity": {
           "type": "boolean"
          },
          "appFull": {
           "type": "boolean"
          },
          "smtpConfigured": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_system | request_system_updates",
    "x-permissions-named": [
     "request_system_updates",
     "view_system"
    ],
    "x-catalogue-group": "compliance"
   },
   "post": {
    "operationId": "post_compliance_reports_auto_update_config",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Save the automatic update schedule: the weekly maintenance window and what it installs.",
    "description": "Request, as the catalogue words it: { 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\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + request_system_updates",
    "requestBody": {
     "description": "{ 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",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "enabled": {
          "type": "boolean"
         },
         "time": {
          "type": "string",
          "description": "24-hour HH:MM"
         },
         "timezone": {
          "type": "string",
          "description": "IANA zone"
         },
         "osSecurity": {
          "type": "boolean"
         },
         "osFull": {
          "type": "boolean"
         },
         "appSecurity": {
          "type": "boolean"
         },
         "appFull": {
          "type": "boolean"
         },
         "maxMinutes": {
          "type": "integer"
         }
        },
        "x-keys-not-described": [
         "day"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the object the GET answers"
     },
     "400": {
      "description": "{ error } for a day, time or timezone the server cannot schedule (nothing is stored) | { 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + request_system_updates",
    "x-permissions-named": [
     "request_system_updates"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/file-archives": {
   "get": {
    "operationId": "get_compliance_reports_file_archives",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "List file-store archives (signed documents and the signing key, compliance evidence, candidate résumés), newest first.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 [ { filename, sizeBytes, modifiedAt, local, offbox, restorable, fetch, bucket, foundInStorage? } ]\n\nGuard: bare array; no paging parameters are read\n\nAuth: Session + view_system",
    "responses": {
     "200": {
      "description": "[ { filename, sizeBytes, modifiedAt, local, offbox, restorable, fetch, bucket, foundInStorage? } ]",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_system",
    "x-permissions-named": [
     "view_system"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/restore-files-request": {
   "post": {
    "operationId": "post_compliance_reports_restore_files_request",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Request a restore of the file store from one archive. A different approver must approve it.",
    "description": "Request, as the catalogue words it: { target } required — an archive filename matching zonitix_files_*.tar.gz, no path\n\nReply, as the catalogue words it: 202 { status:'pending_approval', request } | 400 invalid target | 409 another request is pending or running\n\nGuard: always parked for a second administrator; audited as FILES_RESTORE_REQUESTED; expires after 30 minutes\n\nAuth: Session + approve_system_updates",
    "requestBody": {
     "description": "{ target } required — an archive filename matching zonitix_files_*.tar.gz, no path",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "202": {
      "description": "{ status:'pending_approval', request }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "invalid target"
     },
     "409": {
      "description": "another request is pending or running"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_system_updates",
    "x-permissions-named": [
     "approve_system_updates"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/restore-files-approve": {
   "post": {
    "operationId": "post_compliance_reports_restore_files_approve",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Approve the pending file restore so the host agent may run it.",
    "description": "Request, as the catalogue words it: No body (approver = session user).\n\nReply, as the catalogue words it: 200 { approved:true, request } | 403 approver is the requester | 404 nothing awaiting approval | 409 the pending request is not a file restore\n\nGuard: audited as FILES_RESTORE_APPROVED; the host agent takes a safety copy of the current files first\n\nAuth: Session + approve_system_updates",
    "responses": {
     "200": {
      "description": "{ approved:true, request }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "approved": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "request"
         ]
        }
       }
      }
     },
     "403": {
      "description": "approver is the requester"
     },
     "404": {
      "description": "nothing awaiting approval"
     },
     "409": {
      "description": "the pending request is not a file restore"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_system_updates",
    "x-permissions-named": [
     "approve_system_updates"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/backup-destination": {
   "get": {
    "operationId": "get_compliance_reports_backup_destination",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Read the configured off-box backup destination.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + view_system",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "credentialSource": {
           "type": "string",
           "enum": [
            "manual",
            "provider"
           ]
          },
          "hasSecret": {
           "type": "boolean"
          },
          "encrypted": {
           "type": "boolean"
          },
          "restoreTest": {
           "type": "object",
           "x-keys-not-described": [
            "status",
            "ageDays",
            "stale"
           ]
          }
         },
         "x-keys-not-described": [
          "enabled",
          "provider",
          "providerId",
          "endpoint",
          "region",
          "bucket",
          "prefix",
          "accessKeyId",
          "destinationChangedAt",
          "restoreDrill"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_system",
    "x-permissions-named": [
     "view_system"
    ],
    "x-catalogue-group": "compliance"
   },
   "post": {
    "operationId": "post_compliance_reports_backup_destination",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Configure the backup destination: an S3-compatible bucket or an Azure Blob Storage container that you own.",
    "description": "Request, as the catalogue words it: { enabled:bool, provider, credentialSource ('manual'|'provider'), providerId, endpoint, region, bucket, prefix, accessKeyId, secretAccessKey } — all optional but validated when enabled\n\nReply, as the catalogue words it: 200 { enabled, provider, credentialSource, providerId, endpoint, region, bucket, prefix, accessKeyId, hasSecret } (secret never returned) | 400 missing bucket/creds when enabled\n\nGuard: 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)\n\nAuth: Session + approve_system_updates",
    "requestBody": {
     "description": "{ enabled:bool, provider, credentialSource ('manual'|'provider'), providerId, endpoint, region, bucket, prefix, accessKeyId, secretAccessKey } — all optional but validated when enabled",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ enabled, provider, credentialSource, providerId, endpoint, region, bucket, prefix, accessKeyId, hasSecret } (secret never returned)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "missing bucket/creds when enabled"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_system_updates",
    "x-permissions-named": [
     "approve_system_updates"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/backup-destination/test": {
   "post": {
    "operationId": "post_compliance_reports_backup_destination_test",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Verify the backup destination is reachable and writable.",
    "description": "Request, as the catalogue words it: Same shape as save (transient, never persisted): provider, credentialSource, providerId, endpoint, region, bucket, prefix, accessKeyId, secretAccessKey\n\nReply, as the catalogue words it: 200 { bucket, creds, write, read, prune } per-check {ok,error} results | 400 if bucket missing or provider without providerId\n\nGuard: tests the POSTed values, not stored config; runs a real write/read/prune probe against the bucket\n\nAuth: Session + approve_system_updates",
    "requestBody": {
     "description": "Same shape as save (transient, never persisted): provider, credentialSource, providerId, endpoint, region, bucket, prefix, accessKeyId, secretAccessKey",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ bucket, creds, write, read, prune } per-check {ok,error} results",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "if bucket missing or provider without providerId"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_system_updates",
    "x-permissions-named": [
     "approve_system_updates"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/backup-setup": {
   "get": {
    "operationId": "get_compliance_reports_backup_setup",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Mandatory backup setup: the gate every screen of the dashboard reads, plus the checklist for people who can see the System screens.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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 }\n\nGuard: 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\n\nAuth: Session",
    "responses": {
     "200": {
      "description": "{ 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 }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "gate": {
           "type": "object",
           "properties": {
            "phase": {
             "type": "string",
             "enum": [
              "storage",
              "escrow",
              "complete"
             ]
            },
            "mode": {
             "type": [
              "string",
              "null"
             ],
             "enum": [
              "fresh",
              "existing",
              null
             ]
            },
            "completed": {
             "type": "boolean"
            },
            "proven": {
             "type": "boolean"
            },
            "graceEndsAt": {
             "type": [
              "string",
              "null"
             ]
            },
            "escrowDeadlineAt": {
             "type": [
              "string",
              "null"
             ]
            },
            "locked": {
             "type": "boolean"
            },
            "inGrace": {
             "type": "boolean"
            },
            "reason": {
             "type": "string"
            }
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/backup-setup/verify": {
   "post": {
    "operationId": "post_compliance_reports_backup_setup_verify",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Check again: read the newest uploaded backup back from the destination, verify it, and advance backup setup if the facts allow it.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { result:{ advanced:('proven'|'completed')[], reason:string|null, error? }, gate, steps, health, policy } — the same gate, steps, health and policy the GET answers\n\nGuard: 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\n\nAuth: Session + approve_system_updates",
    "responses": {
     "200": {
      "description": "{ result:{ advanced:('proven'|'completed')[], reason:string|null, error? }, gate, steps, health, policy } — the same gate, steps, health and policy the GET answers",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "result": {
           "type": "object",
           "properties": {
            "reason": {
             "type": [
              "string",
              "null"
             ]
            }
           },
           "x-keys-not-described": [
            "advanced",
            "error"
           ]
          }
         },
         "x-keys-not-described": [
          "gate",
          "steps",
          "health",
          "policy"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_system_updates",
    "x-permissions-named": [
     "approve_system_updates"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/reports/backup-providers": {
   "get": {
    "operationId": "get_compliance_reports_backup_providers",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "List already-connected AWS providers reusable as backup credentials.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 [ { id, name, region, verified } ] — AWS hooks only\n\nGuard: bare array; no paging parameters are read\n\nAuth: Session + approve_system_updates",
    "responses": {
     "200": {
      "description": "[ { id, name, region, verified } ] — AWS hooks only",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_system_updates",
    "x-permissions-named": [
     "approve_system_updates"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/compliance/devices": {
   "get": {
    "operationId": "get_compliance_devices",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Compliance view of registered devices.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 [ Device ] — each with Owner:{ id, email }; devices whose enrolment was explicitly not approved are left out\n\nGuard: bare array, returns every device (no paging). For a paged fleet list use GET /user-management/devices\n\nAuth: Session + view_dashboard | manage_compliance",
    "responses": {
     "200": {
      "description": "[ Device ] — each with Owner:{ id, email }; devices whose enrolment was explicitly not approved are left out",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/Device"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_dashboard | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_dashboard"
    ],
    "x-typed-objects": [
     "Device"
    ],
    "x-catalogue-group": "compliance"
   },
   "post": {
    "operationId": "post_compliance_devices",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Register a device for compliance tracking.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 201 { created device row }\n\nGuard: 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\n\nAuth: Session + manage_compliance",
    "requestBody": {
     "description": "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.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ created device row }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/evidence/upload": {
   "post": {
    "operationId": "post_evidence_upload",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Upload an evidence file (multipart).",
    "description": "Request, as the catalogue words it: multipart/form-data, single field 'file' (multer). No JSON body.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_compliance | view_assigned_compliance",
    "requestBody": {
     "description": "multipart/form-data, single field 'file' (multer). No JSON body.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ attachmentUrl, sha256 }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "'No file uploaded' if file missing"
     },
     "403": {
      "description": "a caller holding only view_assigned_compliance who does not own the control"
     },
     "413": {
      "description": "over 25 MB"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance | view_assigned_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_assigned_compliance"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/evidence/files/{filename}": {
   "get": {
    "operationId": "get_evidence_files_filename",
    "tags": [
     "Compliance, Reporting & System Health"
    ],
    "summary": "Download a stored evidence file.",
    "description": "Request, as the catalogue words it: Path: :filename — the stored name returned by the upload, with no path separators\n\nReply, as the catalogue words it: 200 the file as a download | 400 'Invalid filename' (plain text) when the name contains .., / or \\ | 404 'File not found' (plain text)\n\nGuard: not licence-gated, so evidence uploaded while licensed stays readable; every download is audit-logged as COMPLIANCE_EVIDENCE_DOWNLOADED\n\nAuth: Session + manage_compliance | view_assigned_compliance | view_reporting",
    "parameters": [
     {
      "name": "filename",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "the file as a download"
     },
     "400": {
      "description": "'Invalid filename' (plain text) when the name contains .., / or \\"
     },
     "404": {
      "description": "'File not found' (plain text)"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance | view_assigned_compliance | view_reporting",
    "x-permissions-named": [
     "manage_compliance",
     "view_assigned_compliance",
     "view_reporting"
    ],
    "x-catalogue-group": "compliance"
   }
  },
  "/auth/security-lab/launch": {
   "post": {
    "operationId": "post_auth_security_lab_launch",
    "tags": [
     "Security Lab"
    ],
    "summary": "Launch a probe run.",
    "description": "Request, as the catalogue words it: { attackType } required, targetUrl? optional. attackType selects the probe; targetUrl null/omitted = internal (local) probe, present = remote target.\n\nReply, as the catalogue words it: 200 { ok:true, result } | probe result shape from launchAttack. Writes SECURITY_PROBE_LAUNCHED audit log.\n\nGuard: canRun perm (run_security_scan OR manage_compliance) + requireLicense('securityLabEnabled')\n\nAuth: Session + run_security_scan | manage_compliance (licensed)",
    "requestBody": {
     "description": "{ attackType } required, targetUrl? optional. attackType selects the probe; targetUrl null/omitted = internal (local) probe, present = remote target.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok:true, result } | probe result shape from launchAttack. Writes SECURITY_PROBE_LAUNCHED audit log.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "result"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + run_security_scan | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "run_security_scan"
    ],
    "x-licensed": true,
    "x-catalogue-group": "security-lab"
   }
  },
  "/auth/security-lab/results": {
   "get": {
    "operationId": "get_auth_security_lab_results",
    "tags": [
     "Security Lab"
    ],
    "summary": "Latest probe results (compliant vs at-risk).",
    "description": "Request, as the catalogue words it: No query parameters (the gateway forwards none).\n\nReply, as the catalogue words it: 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\n\nGuard: Returns every open finding (no paging)\n\nAuth: Session + view_security_findings | manage_compliance",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "findings": {
           "type": "array",
           "description": "each a SecurityFinding plus its own riskScore"
          },
          "riskScore": {
           "type": [
            "number",
            "null"
           ],
           "description": "null = nothing to score, not 'secure'"
          },
          "lastScanAt": {
           "type": [
            "string",
            "null"
           ],
           "format": "date-time"
          },
          "lastScanBy": {
           "type": [
            "string",
            "null"
           ]
          },
          "total": {
           "type": "integer",
           "description": "the length of findings"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab"
   }
  },
  "/auth/security-lab/assets": {
   "get": {
    "operationId": "get_auth_security_lab_assets",
    "tags": [
     "Security Lab"
    ],
    "summary": "Discovered assets in scope.",
    "description": "Request, as the catalogue words it: No query parameters (the gateway forwards none).\n\nReply, as the catalogue words it: 200 [ asset ]\n\nGuard: bare array, every asset in one reply (no paging), ordered by type then name\n\nAuth: Session + view_security_findings | manage_compliance",
    "responses": {
     "200": {
      "description": "[ asset ]",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab"
   }
  },
  "/auth/security-lab/scopes": {
   "get": {
    "operationId": "get_auth_security_lab_scopes",
    "tags": [
     "Security Lab"
    ],
    "summary": "List authorised scan scopes.",
    "description": "Request, as the catalogue words it: No query parameters (the gateway forwards none).\n\nReply, as the catalogue words it: 200 [ SecurityScope ] — the stored scope rows (name, description, allowedHosts, allowedCidrs, maxProfile, ownerAttestation, enabled, …) with any credential inside testEndpoints masked\n\nGuard: bare array, every scope in one reply (no paging), newest first\n\nAuth: Session + view_security_findings | manage_compliance",
    "responses": {
     "200": {
      "description": "[ SecurityScope ] — the stored scope rows (name, description, allowedHosts, allowedCidrs, maxProfile, ownerAttestation, enabled, …) with any credential inside testEndpoints masked",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab"
   },
   "post": {
    "operationId": "post_auth_security_lab_scopes",
    "tags": [
     "Security Lab"
    ],
    "summary": "Define a scan scope you are authorised to test.",
    "description": "Request, as the catalogue words it: { name } required; description?, allowedHosts?:string[], allowedCidrs?:string[], maxProfile?='standard', ownerAttestation?:bool=false, enabled?:bool=true.\n\nReply, as the catalogue words it: 201 SecurityScope object | 400 {error} if name missing. Writes SECURITY_SCOPE_CREATED log.\n\nGuard: canManageScopes perm (manage_security_scopes OR manage_compliance)\n\nAuth: Session + manage_security_scopes | manage_compliance",
    "requestBody": {
     "description": "{ name } required; description?, allowedHosts?:string[], allowedCidrs?:string[], maxProfile?='standard', ownerAttestation?:bool=false, enabled?:bool=true.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "SecurityScope object"
     },
     "400": {
      "description": "{error} if name missing. Writes SECURITY_SCOPE_CREATED log.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_scopes | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_scopes"
    ],
    "x-catalogue-group": "security-lab"
   }
  },
  "/auth/security-lab/scans": {
   "post": {
    "operationId": "post_auth_security_lab_scans",
    "tags": [
     "Security Lab"
    ],
    "summary": "Create a scan.",
    "description": "Request, as the catalogue words it: { 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) }\n\nReply, as the catalogue words it: 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\n\nGuard: 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.\n\nAuth: Session + run_security_scan | manage_compliance (licensed)",
    "requestBody": {
     "description": "{ 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) }",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "attestation": {
          "type": "boolean"
         }
        },
        "x-keys-not-described": [
         "suite",
         "profile",
         "targetMode",
         "targetUrl",
         "targetAuth",
         "allowInsecureTLS"
        ]
       }
      }
     }
    },
    "responses": {
     "202": {
      "description": "SecurityScan"
     },
     "400": {
      "description": "if remote w/o targetUrl"
     },
     "401": {
      "description": "SUDO_REQUIRED if profile=aggressive w/o a fresh step-up"
     },
     "403": {
      "description": "NOT_AUTHORIZED_TARGET | SCOPE_PROFILE_EXCEEDED"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + run_security_scan | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "run_security_scan"
    ],
    "x-licensed": true,
    "x-catalogue-group": "security-lab"
   },
   "get": {
    "operationId": "get_auth_security_lab_scans",
    "tags": [
     "Security Lab"
    ],
    "summary": "List scans.",
    "description": "Request, as the catalogue words it: No query parameters (the gateway forwards none).\n\nReply, as the catalogue words it: 200 [ SecurityScan ] — the 50 most recent, newest first\n\nGuard: Fixed at 50 rows, there is no paging parameter\n\nAuth: Session + view_security_findings | manage_compliance",
    "responses": {
     "200": {
      "description": "[ SecurityScan ] — the 50 most recent, newest first",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab"
   }
  },
  "/auth/security-lab/scans/{id}": {
   "get": {
    "operationId": "get_auth_security_lab_scans_id",
    "tags": [
     "Security Lab"
    ],
    "summary": "Scan detail and live status.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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'\n\nAuth: Session + view_security_findings | manage_compliance",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityScan primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ scan, findings[], findingsSource:'live'|'snapshot' } — 'snapshot' when the frozen copy taken for the report holds more findings than the live table",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "findings": {
           "type": "array"
          },
          "findingsSource": {
           "type": "string",
           "enum": [
            "live",
            "snapshot"
           ]
          }
         },
         "x-keys-not-described": [
          "scan"
         ]
        }
       }
      }
     },
     "404": {
      "description": "'Scan not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab"
   }
  },
  "/auth/security-lab/scans/{id}/report": {
   "get": {
    "operationId": "get_auth_security_lab_scans_id_report",
    "tags": [
     "Security Lab"
    ],
    "summary": "Export a scan report.",
    "description": "Request, as the catalogue words it: Query: ?format= json (default) | markdown (or md) | sarif | csv\n\nReply, as the catalogue words it: 200 the report as JSON, Markdown (text/markdown), SARIF (JSON) or CSV (attachment zonitix-scan-<id>.csv) | 404 'Scan not found'\n\nGuard: The report is built from a snapshot of the scan's findings, taken on first request\n\nAuth: Session + export_security_report | view_security_findings | manage_compliance",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityScan primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "the report as JSON, Markdown (text/markdown), SARIF (JSON) or CSV (attachment zonitix-scan-<id>.csv)"
     },
     "404": {
      "description": "'Scan not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + export_security_report | view_security_findings | manage_compliance",
    "x-permissions-named": [
     "export_security_report",
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab"
   }
  },
  "/auth/security-lab/scans/{id}/abort": {
   "post": {
    "operationId": "post_auth_security_lab_scans_id_abort",
    "tags": [
     "Security Lab"
    ],
    "summary": "Abort a running scan.",
    "description": "Request, as the catalogue words it: No body. Scan id in path.\n\nReply, as the catalogue words it: 200 updated SecurityScan | 404 if scan not found. Writes SECURITY_SCAN_ABORTED log (warning).\n\nGuard: canRun perm (run_security_scan OR manage_compliance).\n\nAuth: Session + run_security_scan | manage_compliance",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityScan primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "updated SecurityScan"
     },
     "404": {
      "description": "if scan not found. Writes SECURITY_SCAN_ABORTED log (warning)."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + run_security_scan | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "run_security_scan"
    ],
    "x-catalogue-group": "security-lab"
   }
  },
  "/auth/security-lab/findings/{id}": {
   "patch": {
    "operationId": "patch_auth_security_lab_findings_id",
    "tags": [
     "Security Lab"
    ],
    "summary": "Update a finding status (triage, accept risk, close).",
    "description": "Request, as the catalogue words it: All optional: status (open|acknowledged|in_remediation|retest_pending|risk_accepted|false_positive), statusNote, owner, ticketRef. Only supplied fields update.\n\nReply, as the catalogue words it: 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.\n\nGuard: 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.\n\nAuth: Session + manage_security_findings | manage_compliance",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityFinding primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "All optional: status (open|acknowledged|in_remediation|retest_pending|risk_accepted|false_positive), statusNote, owner, ticketRef. Only supplied fields update.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "updated SecurityFinding"
     },
     "400": {
      "description": "invalid status | 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."
     },
     "404": {
      "description": "not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "x-catalogue-group": "security-lab"
   }
  },
  "/auth/security-lab/findings/{id}/retest": {
   "post": {
    "operationId": "post_auth_security_lab_findings_id_retest",
    "tags": [
     "Security Lab"
    ],
    "summary": "Retest a single finding.",
    "description": "Request, as the catalogue words it: No body. Finding id in path; probe+target derived from the stored finding.\n\nReply, as the catalogue words it: 200 { result, finding } (re-fetched finding, may become verified_fixed on clean result) | 404 if finding not found.\n\nGuard: canRun perm (run_security_scan OR manage_compliance) + requireLicense('securityLabEnabled')\n\nAuth: Session + run_security_scan | manage_compliance (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityFinding primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ result, finding } (re-fetched finding, may become verified_fixed on clean result)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "if finding not found."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + run_security_scan | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "run_security_scan"
    ],
    "x-licensed": true,
    "x-catalogue-group": "security-lab"
   }
  },
  "/auth/aws-resources/{providerId}": {
   "get": {
    "operationId": "get_auth_aws_resources_providerId",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "Current AWS inventory and topology for a connected account.",
    "description": "Request, as the catalogue words it: Query: ?refresh=true forces a fresh discovery; otherwise the stored map is returned when one exists\n\nReply, as the catalogue words it: 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\n\nGuard: a refresh calls the cloud provider and can take long; the gateway treats this as a long-running request\n\nAuth: Session + view_cloud_infrastructure (licensed)",
    "parameters": [
     {
      "name": "providerId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_cloud_infrastructure (licensed)",
    "x-permissions-named": [
     "view_cloud_infrastructure"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CloudResourceMap"
    ],
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/aws-snapshots/{providerId}": {
   "get": {
    "operationId": "get_auth_aws_snapshots_providerId",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "List AWS discovery snapshots.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nGuard: bare array, returns every snapshot (no paging)\n\nAuth: Session + view_cloud_infrastructure (licensed)",
    "parameters": [
     {
      "name": "providerId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "[ CloudSnapshot cut down to { id, snapshotDate, metadata } ] for that AWS hook, newest first — the captured inventory is not included; fetch one snapshot for it",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_cloud_infrastructure (licensed)",
    "x-permissions-named": [
     "view_cloud_infrastructure"
    ],
    "x-licensed": true,
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/aws-snapshot-detail/{id}": {
   "get": {
    "operationId": "get_auth_aws_snapshot_detail_id",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "One AWS snapshot in full.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 the CloudSnapshot (typed under Response Objects) in full: { id, providerId, snapshotDate, metadata, nodes, edges, billingData } | 404 'Snapshot not found'\n\nAuth: Session + view_cloud_infrastructure (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "the CloudSnapshot (typed under Response Objects) in full: { id, providerId, snapshotDate, metadata, nodes, edges, billingData }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/CloudSnapshot"
        }
       }
      }
     },
     "404": {
      "description": "'Snapshot not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_cloud_infrastructure (licensed)",
    "x-permissions-named": [
     "view_cloud_infrastructure"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CloudSnapshot"
    ],
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/aws-changes/{providerId}": {
   "get": {
    "operationId": "get_auth_aws_changes_providerId",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "What changed in the AWS inventory between two discovery snapshots.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 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.'\n\nGuard: read-only; one object, not a list\n\nAuth: Session + view_cloud_infrastructure (licensed)",
    "parameters": [
     {
      "name": "providerId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ available:true, since, until, compared?:true (present when ?a and ?b were used), …the difference between the two } | { available:false, reason } when there is no current map, no earlier snapshot, or a named snapshot no longer exists",
      "content": {
       "application/json": {
        "schema": {
         "anyOf": [
          {
           "type": "object",
           "properties": {
            "available": {
             "type": "boolean"
            },
            "compared": {
             "type": "boolean",
             "description": "present when ?a and ?b were used"
            }
           },
           "x-keys-not-described": [
            "since",
            "until"
           ]
          },
          {
           "type": "object",
           "properties": {
            "available": {
             "type": "boolean"
            }
           },
           "x-keys-not-described": [
            "reason"
           ]
          }
         ]
        }
       }
      }
     },
     "400": {
      "description": "'Snapshots belong to a different provider.'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_cloud_infrastructure (licensed)",
    "x-permissions-named": [
     "view_cloud_infrastructure"
    ],
    "x-licensed": true,
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/aws-identity-findings/{providerId}": {
   "get": {
    "operationId": "get_auth_aws_identity_findings_providerId",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "Identity-fusion findings (IAM principals cross-referenced with your employee directory).",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nGuard: computed on request against the cloud provider (long-running); nothing is stored by this call\n\nAuth: Session + view_cloud_infrastructure (licensed)",
    "parameters": [
     {
      "name": "providerId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "findings": {
           "type": "array"
          },
          "incomplete": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "providerId",
          "cloud",
          "scannedAt",
          "totalPrincipals",
          "matchedEmployees",
          "summary"
         ]
        }
       }
      }
     },
     "404": {
      "description": "code PROVIDER_NOT_FOUND"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_cloud_infrastructure (licensed)",
    "x-permissions-named": [
     "view_cloud_infrastructure"
    ],
    "x-licensed": true,
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/aws-identity-remediate/{providerId}": {
   "post": {
    "operationId": "post_auth_aws_identity_remediate_providerId",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "Execute a governed remediation for an AWS identity finding.",
    "description": "Request, as the catalogue words it: { finding: { remediable:boolean, keyIds:string[], principal:string } } required — the finding object from the identity-findings GET (req.body.finding)\n\nReply, as the catalogue words it: 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\n\nGuard: manage_compliance OR manage_security + sudo + license awsMappingEnabled; DRY RUN by default — only mutates AWS keys when REMEDIATION_DRY_RUN=false\n\nAuth: Session + manage_compliance | manage_security + sudo",
    "parameters": [
     {
      "name": "providerId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "{ finding: { remediable:boolean, keyIds:string[], principal:string } } required — the finding object from the identity-findings GET (req.body.finding)",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "finding": {
          "type": "object",
          "properties": {
           "remediable": {
            "type": "boolean"
           },
           "keyIds": {
            "type": "array",
            "items": {
             "type": "string"
            }
           },
           "principal": {
            "type": "string"
           }
          }
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "dry-run(default) { ok:true, dryRun:true, planned[], message } | executed { ok:true, dryRun:false, executed[], message }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "if finding not remediable / no keyIds"
     },
     "404": {
      "description": "provider not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance | manage_security + sudo",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/aws-resources/{providerId}/sync-invoices": {
   "post": {
    "operationId": "post_auth_aws_resources_providerId_sync_invoices",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "Pull AWS cost/billing data into the finance ledger.",
    "description": "Request, as the catalogue words it: No body. providerId in path.\n\nReply, as the catalogue words it: 200 { message, ingested:number } | 200 { message:'No invoices found...', ingested:0 } | 500 'Invalid AWS Provider' or 'AWS Permission Denied: ...invoicing:ListInvoiceSummaries...'\n\nGuard: manage_compliance. Parser hand-off best-effort: per-invoice ingest errors are logged, not surfaced\n\nAuth: Session + manage_compliance",
    "parameters": [
     {
      "name": "providerId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ message, ingested:number } | { message:'No invoices found...', ingested:0 }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "500": {
      "description": "'Invalid AWS Provider' or 'AWS Permission Denied: ...invoicing:ListInvoiceSummaries...'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/azure-resources/{providerId}": {
   "get": {
    "operationId": "get_auth_azure_resources_providerId",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "Current Azure inventory and topology.",
    "description": "Request, as the catalogue words it: Query: ?refresh=true forces a fresh discovery; otherwise the stored map is returned when one exists\n\nReply, as the catalogue words it: 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\n\nGuard: a refresh calls the cloud provider and can take long; the gateway treats this as a long-running request\n\nAuth: Session + view_cloud_infrastructure (licensed)",
    "parameters": [
     {
      "name": "providerId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_cloud_infrastructure (licensed)",
    "x-permissions-named": [
     "view_cloud_infrastructure"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CloudResourceMap"
    ],
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/azure-snapshots/{providerId}": {
   "get": {
    "operationId": "get_auth_azure_snapshots_providerId",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "List Azure discovery snapshots.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nGuard: bare array, returns every snapshot (no paging)\n\nAuth: Session + view_cloud_infrastructure (licensed)",
    "parameters": [
     {
      "name": "providerId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "[ CloudSnapshot cut down to { id, snapshotDate, metadata } ] for that Azure hook, newest first — the captured inventory is not included; fetch one snapshot for it",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_cloud_infrastructure (licensed)",
    "x-permissions-named": [
     "view_cloud_infrastructure"
    ],
    "x-licensed": true,
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/azure-snapshot-detail/{id}": {
   "get": {
    "operationId": "get_auth_azure_snapshot_detail_id",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "One Azure snapshot in full.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 the CloudSnapshot (typed under Response Objects) in full: { id, providerId, snapshotDate, metadata, nodes, edges, billingData } | 404 'Snapshot not found'\n\nAuth: Session + view_cloud_infrastructure (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "the CloudSnapshot (typed under Response Objects) in full: { id, providerId, snapshotDate, metadata, nodes, edges, billingData }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/CloudSnapshot"
        }
       }
      }
     },
     "404": {
      "description": "'Snapshot not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_cloud_infrastructure (licensed)",
    "x-permissions-named": [
     "view_cloud_infrastructure"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CloudSnapshot"
    ],
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/azure-changes/{providerId}": {
   "get": {
    "operationId": "get_auth_azure_changes_providerId",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "What changed in the Azure inventory between two discovery snapshots.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 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.'\n\nGuard: read-only; one object, not a list\n\nAuth: Session + view_cloud_infrastructure (licensed)",
    "parameters": [
     {
      "name": "providerId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ available:true, since, until, compared?:true (present when ?a and ?b were used), …the difference between the two } | { available:false, reason } when there is no current map, no earlier snapshot, or a named snapshot no longer exists",
      "content": {
       "application/json": {
        "schema": {
         "anyOf": [
          {
           "type": "object",
           "properties": {
            "available": {
             "type": "boolean"
            },
            "compared": {
             "type": "boolean",
             "description": "present when ?a and ?b were used"
            }
           },
           "x-keys-not-described": [
            "since",
            "until"
           ]
          },
          {
           "type": "object",
           "properties": {
            "available": {
             "type": "boolean"
            }
           },
           "x-keys-not-described": [
            "reason"
           ]
          }
         ]
        }
       }
      }
     },
     "400": {
      "description": "'Snapshots belong to a different provider.'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_cloud_infrastructure (licensed)",
    "x-permissions-named": [
     "view_cloud_infrastructure"
    ],
    "x-licensed": true,
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/azure-identity-findings/{providerId}": {
   "get": {
    "operationId": "get_auth_azure_identity_findings_providerId",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "Azure/Entra ID identity findings.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nGuard: computed on request against the cloud provider (long-running); nothing is stored by this call\n\nAuth: Session + view_cloud_infrastructure (licensed)",
    "parameters": [
     {
      "name": "providerId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "findings": {
           "type": "array"
          },
          "incomplete": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "providerId",
          "cloud",
          "scannedAt",
          "totalPrincipals",
          "matchedEmployees",
          "summary"
         ]
        }
       }
      }
     },
     "404": {
      "description": "code PROVIDER_NOT_FOUND"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_cloud_infrastructure (licensed)",
    "x-permissions-named": [
     "view_cloud_infrastructure"
    ],
    "x-licensed": true,
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/azure-identity-remediate/{providerId}": {
   "post": {
    "operationId": "post_auth_azure_identity_remediate_providerId",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "Execute a governed remediation for an Azure identity finding.",
    "description": "Request, as the catalogue words it: { finding: { remediable:boolean, keyIds:string[], principal:string } } required — same shared cloud-agnostic handler as AWS (req.body.finding)\n\nReply, as the catalogue words it: 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\n\nGuard: manage_compliance OR manage_security + sudo + license awsMappingEnabled. Azure path always returns honest not-automated (no cloud mutation)\n\nAuth: Session + manage_compliance | manage_security + sudo",
    "parameters": [
     {
      "name": "providerId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "{ finding: { remediable:boolean, keyIds:string[], principal:string } } required — same shared cloud-agnostic handler as AWS (req.body.finding)",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "finding": {
          "type": "object",
          "properties": {
           "remediable": {
            "type": "boolean"
           },
           "keyIds": {
            "type": "array",
            "items": {
             "type": "string"
            }
           },
           "principal": {
            "type": "string"
           }
          }
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok:false, automated:false, planned[], message:'Automated remediation for AZURE_AD isnt available yet...' } — only AWS is automated",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "automated": {
           "type": "boolean"
          },
          "planned": {
           "type": "array"
          }
         },
         "x-keys-not-described": [
          "message"
         ]
        }
       }
      }
     },
     "400": {
      "description": "if finding not remediable / no keyIds"
     },
     "404": {
      "description": "provider not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance | manage_security + sudo",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/azure-resources/{providerId}/sync-invoices": {
   "post": {
    "operationId": "post_auth_azure_resources_providerId_sync_invoices",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "Pull Azure cost data into the finance ledger.",
    "description": "Request, as the catalogue words it: No body. providerId in path.\n\nReply, as the catalogue words it: 200 { message, ingested:number, totalCost:number, currency, subscriptionId, periodFrom, periodTo } | 500 'Invalid Azure Provider' / 'No Azure Subscription ID...' / 'Azure Cost Management query failed...'\n\nGuard: manage_compliance. Requires azureSubscriptionId on provider config and Cost Management Reader role\n\nAuth: Session + manage_compliance",
    "parameters": [
     {
      "name": "providerId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ message, ingested:number, totalCost:number, currency, subscriptionId, periodFrom, periodTo }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ingested": {
           "type": "number"
          },
          "totalCost": {
           "type": "number"
          }
         },
         "x-keys-not-described": [
          "message",
          "currency",
          "subscriptionId",
          "periodFrom",
          "periodTo"
         ]
        }
       }
      }
     },
     "500": {
      "description": "'Invalid Azure Provider' / 'No Azure Subscription ID...' / 'Azure Cost Management query failed...'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/gcp-resources/{providerId}": {
   "get": {
    "operationId": "get_auth_gcp_resources_providerId",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "Current GCP inventory and topology.",
    "description": "Request, as the catalogue words it: Query: ?refresh=true forces a fresh discovery; otherwise the stored map is returned when one exists\n\nReply, as the catalogue words it: 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\n\nGuard: a refresh calls the cloud provider and can take long; the gateway treats this as a long-running request\n\nAuth: Session + view_cloud_infrastructure (licensed)",
    "parameters": [
     {
      "name": "providerId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_cloud_infrastructure (licensed)",
    "x-permissions-named": [
     "view_cloud_infrastructure"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CloudResourceMap"
    ],
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/gcp-snapshots/{providerId}": {
   "get": {
    "operationId": "get_auth_gcp_snapshots_providerId",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "List GCP discovery snapshots.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nGuard: bare array, returns every snapshot (no paging)\n\nAuth: Session + view_cloud_infrastructure (licensed)",
    "parameters": [
     {
      "name": "providerId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "[ CloudSnapshot cut down to { id, snapshotDate, metadata } ] for that GCP hook, newest first — the captured inventory is not included; fetch one snapshot for it",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_cloud_infrastructure (licensed)",
    "x-permissions-named": [
     "view_cloud_infrastructure"
    ],
    "x-licensed": true,
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/gcp-snapshot-detail/{id}": {
   "get": {
    "operationId": "get_auth_gcp_snapshot_detail_id",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "One GCP snapshot in full.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 the CloudSnapshot (typed under Response Objects) in full: { id, providerId, snapshotDate, metadata, nodes, edges, billingData } | 404 'Snapshot not found'\n\nAuth: Session + view_cloud_infrastructure (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "the CloudSnapshot (typed under Response Objects) in full: { id, providerId, snapshotDate, metadata, nodes, edges, billingData }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/CloudSnapshot"
        }
       }
      }
     },
     "404": {
      "description": "'Snapshot not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_cloud_infrastructure (licensed)",
    "x-permissions-named": [
     "view_cloud_infrastructure"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CloudSnapshot"
    ],
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/gcp-changes/{providerId}": {
   "get": {
    "operationId": "get_auth_gcp_changes_providerId",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "What changed in the GCP inventory between two discovery snapshots.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 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.'\n\nGuard: read-only; one object, not a list\n\nAuth: Session + view_cloud_infrastructure (licensed)",
    "parameters": [
     {
      "name": "providerId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ available:true, since, until, compared?:true (present when ?a and ?b were used), …the difference between the two } | { available:false, reason } when there is no current map, no earlier snapshot, or a named snapshot no longer exists",
      "content": {
       "application/json": {
        "schema": {
         "anyOf": [
          {
           "type": "object",
           "properties": {
            "available": {
             "type": "boolean"
            },
            "compared": {
             "type": "boolean",
             "description": "present when ?a and ?b were used"
            }
           },
           "x-keys-not-described": [
            "since",
            "until"
           ]
          },
          {
           "type": "object",
           "properties": {
            "available": {
             "type": "boolean"
            }
           },
           "x-keys-not-described": [
            "reason"
           ]
          }
         ]
        }
       }
      }
     },
     "400": {
      "description": "'Snapshots belong to a different provider.'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_cloud_infrastructure (licensed)",
    "x-permissions-named": [
     "view_cloud_infrastructure"
    ],
    "x-licensed": true,
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/gcp-identity-findings/{providerId}": {
   "get": {
    "operationId": "get_auth_gcp_identity_findings_providerId",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "GCP identity findings.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nGuard: computed on request against the cloud provider (long-running); nothing is stored by this call\n\nAuth: Session + view_cloud_infrastructure (licensed)",
    "parameters": [
     {
      "name": "providerId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "findings": {
           "type": "array"
          },
          "incomplete": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "providerId",
          "cloud",
          "scannedAt",
          "totalPrincipals",
          "matchedEmployees",
          "summary"
         ]
        }
       }
      }
     },
     "404": {
      "description": "code PROVIDER_NOT_FOUND"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_cloud_infrastructure (licensed)",
    "x-permissions-named": [
     "view_cloud_infrastructure"
    ],
    "x-licensed": true,
    "x-catalogue-group": "cloud"
   }
  },
  "/auth/gcp-identity-remediate/{providerId}": {
   "post": {
    "operationId": "post_auth_gcp_identity_remediate_providerId",
    "tags": [
     "Cloud Discovery (AWS / Azure / GCP)"
    ],
    "summary": "Execute a governed remediation for a GCP identity finding.",
    "description": "Request, as the catalogue words it: { finding: { remediable:boolean, keyIds:string[], principal:string } } required — same shared cloud-agnostic handler as AWS (req.body.finding)\n\nReply, as the catalogue words it: 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\n\nGuard: manage_compliance OR manage_security + sudo + license awsMappingEnabled. GCP path always returns honest not-automated (no cloud mutation)\n\nAuth: Session + manage_compliance | manage_security + sudo",
    "parameters": [
     {
      "name": "providerId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ExternalProvider primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "{ finding: { remediable:boolean, keyIds:string[], principal:string } } required — same shared cloud-agnostic handler as AWS (req.body.finding)",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "finding": {
          "type": "object",
          "properties": {
           "remediable": {
            "type": "boolean"
           },
           "keyIds": {
            "type": "array",
            "items": {
             "type": "string"
            }
           },
           "principal": {
            "type": "string"
           }
          }
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok:false, automated:false, planned[], message:'Automated remediation for GCP isnt available yet...' } — only AWS automated",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "automated": {
           "type": "boolean"
          },
          "planned": {
           "type": "array"
          }
         },
         "x-keys-not-described": [
          "message"
         ]
        }
       }
      }
     },
     "400": {
      "description": "if finding not remediable / no keyIds"
     },
     "404": {
      "description": "provider not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance | manage_security + sudo",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "cloud"
   }
  },
  "/recruitment/job-postings": {
   "get": {
    "operationId": "get_recruitment_job_postings",
    "tags": [
     "Recruitment & Hiring"
    ],
    "summary": "List job postings.",
    "description": "Request, as the catalogue words it: No query parameters are read: the list is not paged, filtered or sorted by the caller.\n\nReply, as the catalogue words it: 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\n\nGuard: a caller who holds manage_finance but none of manage_recruitment, view_recruitment or conduct_interview gets every posting with an empty Candidates list\n\nAuth: Session + manage_recruitment | view_recruitment | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "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"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "JobPosting"
    ],
    "x-catalogue-group": "recruitment"
   },
   "post": {
    "operationId": "post_recruitment_job_postings",
    "tags": [
     "Recruitment & Hiring"
    ],
    "summary": "Create a job posting.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_recruitment (licensed)",
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/JobPostingWrite"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "JobPosting",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/JobPosting"
        }
       }
      }
     },
     "409": {
      "description": "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"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "JobPosting",
     "JobPostingWrite"
    ],
    "x-catalogue-group": "recruitment"
   }
  },
  "/recruitment/job-postings/options": {
   "get": {
    "operationId": "get_recruitment_job_postings_options",
    "tags": [
     "Recruitment & Hiring"
    ],
    "summary": "Requisition choices for the Add candidate picker.",
    "description": "Request, as the catalogue words it: No query parameters; not paged.\n\nReply, as the catalogue words it: 200 [{ id:integer, title:string, department:string|null, status:string }], ordered by title — live requisitions only, the talent pool excluded\n\nAuth: Session + manage_recruitment | view_recruitment | source_candidates (licensed)",
    "responses": {
     "200": {
      "description": "[{ id:integer, title:string, department:string|null, status:string }], ordered by title — live requisitions only, the talent pool excluded",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "type": "object",
          "properties": {
           "id": {
            "type": "integer"
           },
           "title": {
            "type": "string"
           },
           "department": {
            "type": [
             "string",
             "null"
            ]
           },
           "status": {
            "type": "string"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment | source_candidates (licensed)",
    "x-permissions-named": [
     "manage_recruitment",
     "source_candidates",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment"
   }
  },
  "/recruitment/job-postings/{id}": {
   "put": {
    "operationId": "put_recruitment_job_postings_id",
    "tags": [
     "Recruitment & Hiring"
    ],
    "summary": "Update a job posting.",
    "description": "Request, as the catalogue words it: The JobPostingWrite object, all keys optional; only the keys sent are changed and any other key is ignored.\n\nReply, as the catalogue words it: 200 updated JobPosting | 404 'Job Posting not found'\n\nGuard: setting status to Open/Published requires financeApprovalStatus==='Approved' else 409 REQUISITION_NOT_BUDGET_APPROVED; finance fields not writable\n\nAuth: Session + manage_recruitment (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: JobPosting primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The JobPostingWrite object, all keys optional; only the keys sent are changed and any other key is ignored.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/JobPostingWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "updated JobPosting",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/JobPosting"
        }
       }
      }
     },
     "404": {
      "description": "'Job Posting not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "JobPosting",
     "JobPostingWrite"
    ],
    "x-catalogue-group": "recruitment"
   },
   "delete": {
    "operationId": "delete_recruitment_job_postings_id",
    "tags": [
     "Recruitment & Hiring"
    ],
    "summary": "Delete a requisition.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 result | 409 { error:string } when candidates are still linked | 404 { error:string } when it does not exist\n\nGuard: Repeat: a second call answers 404, because the requisition is gone\n\nAuth: Session + manage_recruitment (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: JobPosting primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "result"
     },
     "404": {
      "description": "{ error:string } when it does not exist",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "409": {
      "description": "{ error:string } when candidates are still linked",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment"
   }
  },
  "/recruitment/job-postings/{id}/owner": {
   "put": {
    "operationId": "put_recruitment_job_postings_id_owner",
    "tags": [
     "Recruitment & Hiring"
    ],
    "summary": "Reassign the HR owner of a requisition, optionally cascading to its candidates.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 200 { id:integer, ownerId:integer|null, cascaded:integer } (cascaded is the count of candidates moved to the new owner)\n\nGuard: Repeat: the same owner is stored again, the answer is 200 and another RECRUITMENT_REQUISITION_REASSIGNED audit entry is written\n\nAuth: Session + manage_recruitment (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: JobPosting primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/RequisitionOwnerWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ id:integer, ownerId:integer|null, cascaded:integer } (cascaded is the count of candidates moved to the new owner)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "integer"
          },
          "ownerId": {
           "type": [
            "integer",
            "null"
           ]
          },
          "cascaded": {
           "type": "integer"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "RequisitionOwnerWrite"
    ],
    "x-catalogue-group": "recruitment"
   }
  },
  "/recruitment/job-postings/{id}/approve-finance": {
   "post": {
    "operationId": "post_recruitment_job_postings_id_approve_finance",
    "tags": [
     "Recruitment & Hiring"
    ],
    "summary": "Independent finance approval of headcount/budget.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 updated JobPosting (financeApprovalStatus=Approved) | 404 'Job Posting not found' | 409 code ALREADY_APPROVED — so a repeat changes nothing\n\nGuard: 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\n\nAuth: Session + manage_finance (only) (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: JobPosting primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "updated JobPosting (financeApprovalStatus=Approved)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/JobPosting"
        }
       }
      }
     },
     "404": {
      "description": "'Job Posting not found'"
     },
     "409": {
      "description": "code ALREADY_APPROVED — so a repeat changes nothing"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (only) (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "JobPosting"
    ],
    "x-catalogue-group": "recruitment"
   }
  },
  "/recruitment/job-postings/{id}/reject-finance": {
   "post": {
    "operationId": "post_recruitment_job_postings_id_reject_finance",
    "tags": [
     "Recruitment & Hiring"
    ],
    "summary": "Refuse the headcount budget of a requisition.",
    "description": "Request, as the catalogue words it: The BudgetRefusal object: { reason:string } required: at least 3 characters, cut to 1000.\n\nReply, as the catalogue words it: 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)\n\nGuard: 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\n\nAuth: Session + manage_finance (only) (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: JobPosting primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The BudgetRefusal object: { reason:string } required: at least 3 characters, cut to 1000.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/BudgetRefusal"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "updated JobPosting (the reason is stored on it)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/JobPosting"
        }
       }
      }
     },
     "400": {
      "description": "code REASON_REQUIRED when the reason is shorter than 3 characters (it is cut to 1000)"
     },
     "404": {
      "description": "'Job Posting not found'"
     },
     "409": {
      "description": "code ALREADY_REJECTED — so a repeat changes nothing"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (only) (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "JobPosting",
     "BudgetRefusal"
    ],
    "x-catalogue-group": "recruitment"
   }
  },
  "/recruitment/job-postings/{id}/resubmit-budget": {
   "post": {
    "operationId": "post_recruitment_job_postings_id_resubmit_budget",
    "tags": [
     "Recruitment & Hiring"
    ],
    "summary": "Send a refused budget back to Finance after changing the terms (Rejected back to Pending).",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 updated JobPosting | 404 { error:string } | 409 code NOT_REJECTED when the budget is not currently refused\n\nGuard: Repeat: a second call answers 409 with code NOT_REJECTED, because the budget is Pending again\n\nAuth: Session + manage_recruitment (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: JobPosting primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "updated JobPosting",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/JobPosting"
        }
       }
      }
     },
     "404": {
      "description": "{ error:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "409": {
      "description": "code NOT_REJECTED when the budget is not currently refused"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "JobPosting"
    ],
    "x-catalogue-group": "recruitment"
   }
  },
  "/recruitment/job-postings/{id}/activity": {
   "get": {
    "operationId": "get_recruitment_job_postings_id_activity",
    "tags": [
     "Recruitment & Hiring"
    ],
    "summary": "Activity trail of one requisition.",
    "description": "Request, as the catalogue words it: Query: limit (default 50, clamped to 1–200).\n\nReply, as the catalogue words it: 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)\n\nAuth: Session + manage_recruitment | view_recruitment | manage_finance (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: JobPosting primary key.",
      "x-type-basis": "model"
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 50,
       "minimum": 1,
       "maximum": 200
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-paging-parameters": [
     "limit"
    ],
    "x-catalogue-group": "recruitment"
   }
  },
  "/recruitment/competencies/suggest": {
   "post": {
    "operationId": "post_recruitment_competencies_suggest",
    "tags": [
     "Recruitment & Hiring"
    ],
    "summary": "Suggest scorecard competencies for a role.",
    "description": "Request, as the catalogue words it: The CompetencySuggest object: { jobTitle:string } required; jobDescription?:string.\n\nReply, as the catalogue words it: 200 { competencies } | 400 if jobTitle missing | 502 if AI generation fails\n\nAuth: Session + manage_recruitment (licensed)",
    "requestBody": {
     "description": "The CompetencySuggest object: { jobTitle:string } required; jobDescription?:string.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/CompetencySuggest"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ competencies }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "if jobTitle missing"
     },
     "502": {
      "description": "if AI generation fails"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CompetencySuggest"
    ],
    "x-catalogue-group": "recruitment"
   }
  },
  "/recruitment/job-description/suggest": {
   "post": {
    "operationId": "post_recruitment_job_description_suggest",
    "tags": [
     "Recruitment & Hiring"
    ],
    "summary": "Draft a job description with the local LLM.",
    "description": "Request, as the catalogue words it: The JobDescriptionSuggest object: { jobTitle:string } required; the other role fields of the create-job form are passed through to the model.\n\nReply, as the catalogue words it: 200 { description:string } | 400 when jobTitle is missing | 502 when generation fails\n\nAuth: Session + manage_recruitment (licensed)",
    "requestBody": {
     "description": "The JobDescriptionSuggest object: { jobTitle:string } required; the other role fields of the create-job form are passed through to the model.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/JobDescriptionSuggest"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ description:string }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "description": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "when jobTitle is missing"
     },
     "502": {
      "description": "when generation fails"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "JobDescriptionSuggest"
    ],
    "x-catalogue-group": "recruitment"
   }
  },
  "/recruitment/candidates": {
   "get": {
    "operationId": "get_recruitment_candidates",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "List candidates in the pipeline.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: the service applies the caller's candidate scope, so the list holds only candidates that caller may see\n\nAuth: Session + manage_recruitment | view_recruitment | source_candidates | schedule_interviews | review_executive_packet | conduct_interview (licensed)",
    "parameters": [
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 200,
       "minimum": 1,
       "maximum": 500
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "offset",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 0,
       "minimum": 0
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "ownerId",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "search",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "sort",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "stage",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "tag",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "jobPostingId",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "A requisition id."
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "candidates": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/Candidate"
           }
          },
          "total": {
           "type": "integer"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment | source_candidates | schedule_interviews | review_executive_packet | conduct_interview (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "review_executive_packet",
     "schedule_interviews",
     "source_candidates",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-paging-parameters": [
     "limit",
     "offset"
    ],
    "x-typed-objects": [
     "Candidate"
    ],
    "x-catalogue-group": "recruitment-candidates"
   },
   "post": {
    "operationId": "post_recruitment_candidates",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Add a candidate.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 201 Candidate | 409 code CANDIDATE_ALREADY_EXISTS when that email is already on the requisition\n\nGuard: duplicate-email check on create; a repeat with the same email on the same requisition answers 409 and creates nothing\n\nAuth: Session + manage_recruitment | source_candidates (licensed)",
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/CandidateCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "Candidate",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Candidate"
        }
       }
      }
     },
     "409": {
      "description": "code CANDIDATE_ALREADY_EXISTS when that email is already on the requisition"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | source_candidates (licensed)",
    "x-permissions-named": [
     "manage_recruitment",
     "source_candidates"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Candidate",
     "CandidateCreate"
    ],
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/summary": {
   "get": {
    "operationId": "get_recruitment_candidates_summary",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Pipeline counts for the dashboard, scoped like the list.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { byStage:{ <stage>: integer } (only stages that hold a candidate), total:integer, hired:integer, rejected:integer, hiredThisMonth:integer, hiredLastMonth:integer }\n\nAuth: Session + manage_recruitment | view_recruitment | source_candidates | schedule_interviews | review_executive_packet | conduct_interview (licensed)",
    "responses": {
     "200": {
      "description": "{ byStage:{ <stage>: integer } (only stages that hold a candidate), total:integer, hired:integer, rejected:integer, hiredThisMonth:integer, hiredLastMonth:integer }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "byStage": {
           "type": "object",
           "additionalProperties": {
            "type": "integer"
           },
           "description": "only stages that hold a candidate"
          },
          "total": {
           "type": "integer"
          },
          "hired": {
           "type": "integer"
          },
          "rejected": {
           "type": "integer"
          },
          "hiredThisMonth": {
           "type": "integer"
          },
          "hiredLastMonth": {
           "type": "integer"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment | source_candidates | schedule_interviews | review_executive_packet | conduct_interview (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "review_executive_packet",
     "schedule_interviews",
     "source_candidates",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/filter-options": {
   "get": {
    "operationId": "get_recruitment_candidates_filter_options",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Owner and tag choices for the pipeline filters, over the same scoped set as the list.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nAuth: Session + manage_recruitment | view_recruitment | source_candidates | schedule_interviews | review_executive_packet | conduct_interview (licensed)",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "hasUnassigned": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "owners",
          "tags"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment | source_candidates | schedule_interviews | review_executive_packet | conduct_interview (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "review_executive_packet",
     "schedule_interviews",
     "source_candidates",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/bulk": {
   "post": {
    "operationId": "post_recruitment_candidates_bulk",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Apply one action to many candidates: move stage, reject, reassign or tag.",
    "description": "Request, as the catalogue words it: 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).\n\nReply, as the catalogue words it: 200 { succeeded:integer[], failed:[{ id:integer, error:string }], skipped:[{ id:integer, reason:string }] } | 400 empty list, more than 200 ids, or an unknown action\n\nGuard: 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\n\nAuth: Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "requestBody": {
     "description": "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).",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/CandidateBulkAction"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ succeeded:integer[], failed:[{ id:integer, error:string }], skipped:[{ id:integer, reason:string }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "succeeded": {
           "type": "array",
           "items": {
            "type": "integer"
           }
          },
          "failed": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "id": {
              "type": "integer"
             },
             "error": {
              "type": "string"
             }
            }
           }
          },
          "skipped": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "id": {
              "type": "integer"
             },
             "reason": {
              "type": "string"
             }
            }
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "empty list, more than 200 ids, or an unknown action"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "source_candidates"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CandidateBulkAction"
    ],
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/bulk-offer-decision": {
   "post": {
    "operationId": "post_recruitment_candidates_bulk_offer_decision",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Approve or refuse many offers that are awaiting approval.",
    "description": "Request, as the catalogue words it: The OfferBulkDecision object: { ids:integer[] (1 to 200), approve:boolean, reason?:string, termsVersions?:{ <candidateId>: string } }. Only approve:true approves; anything else refuses.\n\nReply, as the catalogue words it: 200 { succeeded:integer[], failed:[{ id:integer, error:string, code?:string }] } | 400 empty list or more than 200 ids\n\nGuard: each offer goes through the same service call as the single approve and refuse routes, including the self-approval block\n\nAuth: Session + manage_recruitment | approve_offer | manage_finance (licensed)",
    "requestBody": {
     "description": "The OfferBulkDecision object: { ids:integer[] (1 to 200), approve:boolean, reason?:string, termsVersions?:{ <candidateId>: string } }. Only approve:true approves; anything else refuses.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/OfferBulkDecision"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ succeeded:integer[], failed:[{ id:integer, error:string, code?:string }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "succeeded": {
           "type": "array",
           "items": {
            "type": "integer"
           }
          },
          "failed": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "id": {
              "type": "integer"
             },
             "error": {
              "type": "string"
             },
             "code": {
              "type": "string"
             }
            }
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "empty list or more than 200 ids"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | approve_offer | manage_finance (licensed)",
    "x-permissions-named": [
     "approve_offer",
     "manage_finance",
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "OfferBulkDecision"
    ],
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/ai-screen": {
   "post": {
    "operationId": "post_recruitment_candidates_ai_screen",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Screen a candidate against the job's requirements (demographic fields stripped first).",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: AI screening strips demographic fields before scoring; the screening decision is audited\n\nAuth: Session + manage_recruitment | source_candidates (licensed)",
    "requestBody": {
     "description": "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.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "Candidate with matchScore",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Candidate"
        }
       }
      }
     },
     "400": {
      "description": "if jobPostingId missing or screen error"
     },
     "404": {
      "description": "'Job Posting not found'"
     },
     "409": {
      "description": "code CANDIDATE_ALREADY_EXISTS when that email is already on the requisition"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | source_candidates (licensed)",
    "x-permissions-named": [
     "manage_recruitment",
     "source_candidates"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Candidate",
     "CandidateCreate"
    ],
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/{id}/rescreen": {
   "post": {
    "operationId": "post_recruitment_candidates_id_rescreen",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Re-run screening for a candidate.",
    "description": "Request, as the catalogue words it: No body — re-screens using the candidate's stored résumé.\n\nReply, as the catalogue words it: 200 { status:string } (e.g. 'started')\n\nAuth: Session + manage_recruitment | source_candidates (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ status:string } (e.g. 'started')",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "status": {
           "type": "string"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | source_candidates (licensed)",
    "x-permissions-named": [
     "manage_recruitment",
     "source_candidates"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/parse-resume": {
   "post": {
    "operationId": "post_recruitment_candidates_parse_resume",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Parse an uploaded resume into structured fields.",
    "description": "Request, as the catalogue words it: multipart/form-data: resume (single file). No other fields.\n\nReply, as the catalogue words it: 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'\n\nGuard: fail-open: on extraction failure returns empty fields + warning (200), never 500\n\nAuth: Session + manage_recruitment | source_candidates (licensed)",
    "requestBody": {
     "description": "multipart/form-data: resume (single file). No other fields.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ firstName:string, lastName:string, email:string, phone:string } (empty strings, plus warning:string, when the file could not be read)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "firstName": {
           "type": "string"
          },
          "lastName": {
           "type": "string"
          },
          "email": {
           "type": "string"
          },
          "phone": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "'No resume uploaded'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | source_candidates (licensed)",
    "x-permissions-named": [
     "manage_recruitment",
     "source_candidates"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/{id}/onboarding-status": {
   "get": {
    "operationId": "get_recruitment_candidates_id_onboarding_status",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Onboarding run status and step progress for a hired candidate.",
    "description": "Reply, as the catalogue words it: 200 onboarding status | 404 'Candidate not found' when the caller has no broad access and is not related to the candidate\n\nAuth: Session + manage_recruitment | view_recruitment (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "onboarding status"
     },
     "404": {
      "description": "'Candidate not found' when the caller has no broad access and is not related to the candidate"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/{id}/history": {
   "get": {
    "operationId": "get_recruitment_candidates_id_history",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "This person's earlier applications.",
    "description": "Reply, as the catalogue words it: 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'\n\nAuth: Session + manage_recruitment | view_recruitment | conduct_interview | source_candidates | review_executive_packet (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "email": {
           "type": "string"
          },
          "priorCount": {
           "type": "integer"
          },
          "priorApplications": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "id": {
              "type": "integer"
             },
             "jobPostingId": {
              "type": "integer"
             },
             "jobTitle": {
              "type": "string"
             },
             "department": {
              "type": [
               "string",
               "null"
              ]
             },
             "stage": {
              "type": "string"
             },
             "source": {
              "type": [
               "string",
               "null"
              ]
             },
             "appliedAt": {
              "type": "string",
              "format": "date-time"
             }
            }
           }
          },
          "everHired": {
           "type": "boolean"
          },
          "everRejected": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "'Candidate not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment | conduct_interview | source_candidates | review_executive_packet (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "review_executive_packet",
     "source_candidates",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/{id}/messages": {
   "get": {
    "operationId": "get_recruitment_candidates_id_messages",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Email thread with a candidate.",
    "description": "Reply, as the catalogue words it: 200 [ CandidateMessage ], oldest first, at most 200; not paged\n\nAuth: Session + manage_recruitment | view_recruitment | conduct_interview | source_candidates | review_executive_packet (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "[ CandidateMessage ], oldest first, at most 200; not paged",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/CandidateMessage"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment | conduct_interview | source_candidates | review_executive_packet (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "review_executive_packet",
     "source_candidates",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CandidateMessage"
    ],
    "x-catalogue-group": "recruitment-candidates"
   },
   "post": {
    "operationId": "post_recruitment_candidates_id_messages",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Send an email to a candidate from the thread.",
    "description": "Request, as the catalogue words it: The CandidateMessageSend object: { subject:string, body:string }, both required and not blank; subject is cut to 500 characters.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: not deduplicated: every call sends another email and stores another message\n\nAuth: Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The CandidateMessageSend object: { subject:string, body:string }, both required and not blank; subject is cut to 500 characters.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/CandidateMessageSend"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "the stored message"
     },
     "409": {
      "description": "code SMTP_OFF when outgoing email is not configured. 400 when the subject or body is blank, or the candidate has no email address"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "source_candidates"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CandidateMessageSend"
    ],
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/{id}/stage": {
   "patch": {
    "operationId": "patch_recruitment_candidates_id_stage",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Move a candidate between pipeline stages.",
    "description": "Request, as the catalogue words it: The CandidateStageWrite object: { stage:string } required: Sourced | Screen | Interview | Offer | Hired | Rejected, in any capitalisation.\n\nReply, as the catalogue words it: 200 updated Candidate | 409 code OFFER_NOT_SIGNED when moving to Hired while the candidate's offer is not yet signed\n\nGuard: hiring-decision stages (e.g. Hired) restricted to HR; interviewer/sourcer scope enforced in service\n\nAuth: Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The CandidateStageWrite object: { stage:string } required: Sourced | Screen | Interview | Offer | Hired | Rejected, in any capitalisation.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/CandidateStageWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "updated Candidate",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Candidate"
        }
       }
      }
     },
     "409": {
      "description": "code OFFER_NOT_SIGNED when moving to Hired while the candidate's offer is not yet signed"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "source_candidates"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Candidate",
     "CandidateStageWrite"
    ],
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/{id}/tags": {
   "patch": {
    "operationId": "patch_recruitment_candidates_id_tags",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Add or remove recruiter tags on a candidate.",
    "description": "Request, as the catalogue words it: The CandidateTagsUpdate object: { add?:string[], remove?:string[] }. A label is trimmed and cut to 40 characters; a candidate keeps at most 30.\n\nReply, as the catalogue words it: 200 { tags:string[] }\n\nAuth: Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The CandidateTagsUpdate object: { add?:string[], remove?:string[] }. A label is trimmed and cut to 40 characters; a candidate keeps at most 30.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/CandidateTagsUpdate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ tags:string[] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "tags": {
           "type": "array",
           "items": {
            "type": "string"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "source_candidates"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CandidateTagsUpdate"
    ],
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/{id}/notes": {
   "get": {
    "operationId": "get_recruitment_candidates_id_notes",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Internal hiring-team notes on a candidate.",
    "description": "Reply, as the catalogue words it: 200 [ CandidateNote ], newest first, each with its Author; not paged\n\nAuth: Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "[ CandidateNote ], newest first, each with its Author; not paged",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/CandidateNote"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "source_candidates"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CandidateNote"
    ],
    "x-catalogue-group": "recruitment-candidates"
   },
   "post": {
    "operationId": "post_recruitment_candidates_id_notes",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Add an internal note, optionally mentioning colleagues.",
    "description": "Request, as the catalogue words it: The CandidateNoteCreate object: { body:string } required (trimmed, cut to 5000 characters); mentions?:integer[] (user ids to notify).\n\nReply, as the catalogue words it: 201 the stored CandidateNote. 400 'A note cannot be empty.'\n\nGuard: Repeat: not deduplicated: every call stores another note and notifies the people mentioned again\n\nAuth: Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The CandidateNoteCreate object: { body:string } required (trimmed, cut to 5000 characters); mentions?:integer[] (user ids to notify).",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/CandidateNoteCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "the stored CandidateNote. 400 'A note cannot be empty.'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "source_candidates"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CandidateNote",
     "CandidateNoteCreate"
    ],
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/{id}/reject": {
   "post": {
    "operationId": "post_recruitment_candidates_id_reject",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Reject a candidate.",
    "description": "Request, as the catalogue words it: The CandidateReject object: { sendEmail?:boolean, reason?:string } — all optional.\n\nReply, as the catalogue words it: 200 result (adverse-action email sent if sendEmail)\n\nGuard: 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\n\nAuth: Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The CandidateReject object: { sendEmail?:boolean, reason?:string } — all optional.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/CandidateReject"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "result (adverse-action email sent if sendEmail)"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "source_candidates"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CandidateReject"
    ],
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/{id}/calibrate": {
   "post": {
    "operationId": "post_recruitment_candidates_id_calibrate",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Calibrate scorecards across interviewers.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 calibration result (interviewer names/scores/feedback)\n\nGuard: assertCanActOnCandidate — real relationship required, 404 fail-closed\n\nAuth: Session + manage_recruitment | view_recruitment (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "calibration result (interviewer names/scores/feedback)"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/{id}/send-rejection-email": {
   "post": {
    "operationId": "post_recruitment_candidates_id_send_rejection_email",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Send a rejection email.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 result — sends the adverse-action/rejection email | 409 code SMTP_OFF when outgoing email is not configured (nothing is sent)\n\nAuth: Session + manage_recruitment | conduct_interview (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "result — sends the adverse-action/rejection email"
     },
     "409": {
      "description": "code SMTP_OFF when outgoing email is not configured (nothing is sent)"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | conduct_interview (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/{id}/owner": {
   "put": {
    "operationId": "put_recruitment_candidates_id_owner",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Reassign a candidate's HR owner.",
    "description": "Request, as the catalogue words it: The CandidateOwnerWrite object: { ownerId:integer|null }.\n\nReply, as the catalogue words it: 200 { id:integer, ownerId:integer|null }\n\nGuard: Repeat: the same owner is stored again, the answer is 200 and another RECRUITMENT_CANDIDATE_REASSIGNED audit entry is written\n\nAuth: Session + manage_recruitment (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The CandidateOwnerWrite object: { ownerId:integer|null }.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/CandidateOwnerWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ id:integer, ownerId:integer|null }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "integer"
          },
          "ownerId": {
           "type": [
            "integer",
            "null"
           ]
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CandidateOwnerWrite"
    ],
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/{id}": {
   "delete": {
    "operationId": "delete_recruitment_candidates_id",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Purge a candidate and their files (GDPR erasure).",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nAuth: Session + manage_recruitment",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ result } — permanent purge including the résumé file, audited",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "207": {
      "description": "with the same body when the local purge succeeded but a signed document could not be erased downstream"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/{id}/data-export": {
   "get": {
    "operationId": "get_recruitment_candidates_id_data_export",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Export everything held about one candidate (data-subject access request).",
    "description": "Reply, as the catalogue words it: 200 JSON attachment candidate-<id>-data-export.json | the service error status with { error:string }\n\nAuth: Session + manage_recruitment",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "JSON attachment candidate-<id>-data-export.json | the service error status with { error:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/candidates/{id}/resume": {
   "get": {
    "operationId": "get_recruitment_candidates_id_resume",
    "tags": [
     "Recruitment: Candidates & Pipeline"
    ],
    "summary": "Stream a CV to the in-app viewer.",
    "description": "Reply, as the catalogue words it: 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'\n\nGuard: 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\n\nAuth: Session + manage_recruitment | view_recruitment | conduct_interview | source_candidates | review_executive_packet",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "the file, served inline with a Content-Type chosen from its extension"
     },
     "400": {
      "description": "text 'Invalid filename'"
     },
     "404": {
      "description": "text 'Resume not found' — also for a caller with no relationship to the candidate | text 'File not found on disk'"
     },
     "500": {
      "description": "text 'Could not open resume'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment | conduct_interview | source_candidates | review_executive_packet",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "review_executive_packet",
     "source_candidates",
     "view_recruitment"
    ],
    "x-catalogue-group": "recruitment-candidates"
   }
  },
  "/recruitment/interviews/{id}": {
   "get": {
    "operationId": "get_recruitment_interviews_id",
    "tags": [
     "Recruitment: Interviews & Scorecards"
    ],
    "summary": "Interview detail and scorecards.",
    "description": "Reply, as the catalogue words it: 200 Interview | 404 'Interview not found' — also returned to a caller who is not on that interview's panel\n\nAuth: Session + manage_recruitment | view_recruitment | conduct_interview | schedule_interviews | review_executive_packet (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Interview primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "Interview",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Interview"
        }
       }
      }
     },
     "404": {
      "description": "'Interview not found' — also returned to a caller who is not on that interview's panel"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment | conduct_interview | schedule_interviews | review_executive_packet (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "review_executive_packet",
     "schedule_interviews",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Interview"
    ],
    "x-catalogue-group": "recruitment-interviews"
   }
  },
  "/recruitment/candidates/{id}/interviews": {
   "post": {
    "operationId": "post_recruitment_candidates_id_interviews",
    "tags": [
     "Recruitment: Interviews & Scorecards"
    ],
    "summary": "Schedule an interview.",
    "description": "Request, as the catalogue words it: The InterviewCreate object: { interviewerId:integer } required; scheduledAt?:string (date-time), sequence?:integer, interviewTemplateId?:integer, isShadow?:boolean, durationMins?:integer, allowOverlap?:boolean.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_recruitment | conduct_interview | schedule_interviews (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The InterviewCreate object: { interviewerId:integer } required; scheduledAt?:string (date-time), sequence?:integer, interviewTemplateId?:integer, isShadow?:boolean, durationMins?:integer, allowOverlap?:boolean.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/InterviewCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "Interview (interviewer notified best-effort). 400 code INVALID_TIME when scheduledAt is not a date",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Interview"
        }
       }
      }
     },
     "409": {
      "description": "code DOUBLE_BOOKED when the interviewer already has a scheduled interview in that time window and allowOverlap is not true"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | conduct_interview | schedule_interviews (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "schedule_interviews"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Interview",
     "InterviewCreate"
    ],
    "x-catalogue-group": "recruitment-interviews"
   }
  },
  "/recruitment/candidates/{id}/interviews/propose": {
   "post": {
    "operationId": "post_recruitment_candidates_id_interviews_propose",
    "tags": [
     "Recruitment: Interviews & Scorecards"
    ],
    "summary": "Propose interview times for the candidate to pick from (self-scheduling).",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_recruitment | conduct_interview | schedule_interviews (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/InterviewSlotsPropose"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ id:integer, proposedSlots:array }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "integer"
          },
          "proposedSlots": {
           "type": "array"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "code INTERVIEWER_REQUIRED or NOT_ENOUGH_SLOTS. 409 code DOUBLE_BOOKED when fewer than two of the times are free for the interviewer"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | conduct_interview | schedule_interviews (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "schedule_interviews"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "InterviewSlotsPropose"
    ],
    "x-catalogue-group": "recruitment-interviews"
   }
  },
  "/recruitment/interviews/{id}/reschedule": {
   "patch": {
    "operationId": "patch_recruitment_interviews_id_reschedule",
    "tags": [
     "Recruitment: Interviews & Scorecards"
    ],
    "summary": "Move an interview to a new time.",
    "description": "Request, as the catalogue words it: The InterviewReschedule object: { scheduledAt:string (date-time) } required; durationMins?:integer, allowOverlap?:boolean.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_recruitment | conduct_interview | schedule_interviews (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Interview primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The InterviewReschedule object: { scheduledAt:string (date-time) } required; durationMins?:integer, allowOverlap?:boolean.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/InterviewReschedule"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Interview"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | conduct_interview | schedule_interviews (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "schedule_interviews"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Interview",
     "InterviewReschedule"
    ],
    "x-catalogue-group": "recruitment-interviews"
   }
  },
  "/recruitment/interviews/{id}/cancel": {
   "post": {
    "operationId": "post_recruitment_interviews_id_cancel",
    "tags": [
     "Recruitment: Interviews & Scorecards"
    ],
    "summary": "Cancel an interview.",
    "description": "Request, as the catalogue words it: The InterviewCancel object: { reason?:string }, cut to 500 characters.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: safe; an interview that is already cancelled is returned unchanged and the interviewer is not notified again\n\nAuth: Session + manage_recruitment | conduct_interview | schedule_interviews (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Interview primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The InterviewCancel object: { reason?:string }, cut to 500 characters.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/InterviewCancel"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Interview"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | conduct_interview | schedule_interviews (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "schedule_interviews"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Interview",
     "InterviewCancel"
    ],
    "x-catalogue-group": "recruitment-interviews"
   }
  },
  "/recruitment/interviews/{id}/scorecard": {
   "post": {
    "operationId": "post_recruitment_interviews_id_scorecard",
    "tags": [
     "Recruitment: Interviews & Scorecards"
    ],
    "summary": "Submit an interview scorecard.",
    "description": "Request, as the catalogue words it: The ScorecardSubmit object: { structuredFeedback?:object, score?:integer (1 to 5), feedback?:string }; the recommendation is carried inside structuredFeedback. Sets status to Completed.\n\nReply, as the catalogue words it: 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)\n\nGuard: 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\n\nAuth: Session + manage_recruitment | conduct_interview (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Interview primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The ScorecardSubmit object: { structuredFeedback?:object, score?:integer (1 to 5), feedback?:string }; the recommendation is carried inside structuredFeedback. Sets status to Completed.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/ScorecardSubmit"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "updated Interview",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Interview"
        }
       }
      }
     },
     "400": {
      "description": "code INVALID_SCORE (score must be 1–5)"
     },
     "403": {
      "description": "code SCORECARD_NOT_ASSIGNED"
     },
     "409": {
      "description": "code INTERVIEW_CANCELLED | code SCORECARD_LOCKED"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | conduct_interview (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Interview",
     "ScorecardSubmit"
    ],
    "x-catalogue-group": "recruitment-interviews"
   }
  },
  "/recruitment/interview-templates": {
   "get": {
    "operationId": "get_recruitment_interview_templates",
    "tags": [
     "Recruitment: Interviews & Scorecards"
    ],
    "summary": "List interview templates.",
    "description": "Request, as the catalogue words it: Query: jobPostingId?\n\nReply, as the catalogue words it: 200 bare array of InterviewTemplate, each with usageCount and its Questions in order; not paged — jobPostingId is the only filter\n\nAuth: Session + manage_recruitment | conduct_interview | view_recruitment (licensed)",
    "responses": {
     "200": {
      "description": "bare array of InterviewTemplate, each with usageCount and its Questions in order; not paged — jobPostingId is the only filter"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | conduct_interview | view_recruitment (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "InterviewTemplate"
    ],
    "x-catalogue-group": "recruitment-interviews"
   },
   "post": {
    "operationId": "post_recruitment_interview_templates",
    "tags": [
     "Recruitment: Interviews & Scorecards"
    ],
    "summary": "Create an interview template.",
    "description": "Request, as the catalogue words it: 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 }.\n\nReply, as the catalogue words it: 201 InterviewTemplate (with questions)\n\nGuard: Repeat: not deduplicated: every call creates another template, also with the same name\n\nAuth: Session + manage_recruitment (licensed)",
    "requestBody": {
     "description": "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 }.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/InterviewTemplateWrite"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "InterviewTemplate (with questions)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/InterviewTemplate"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "InterviewTemplate",
     "InterviewQuestionInput",
     "InterviewTemplateWrite"
    ],
    "x-catalogue-group": "recruitment-interviews"
   }
  },
  "/recruitment/interview-templates/{id}": {
   "put": {
    "operationId": "put_recruitment_interview_templates_id",
    "tags": [
     "Recruitment: Interviews & Scorecards"
    ],
    "summary": "Update an interview template.",
    "description": "Request, as the catalogue words it: The InterviewTemplateWrite object, all keys optional. Sending questions replaces the question list; leaving it out keeps the questions as they are.\n\nReply, as the catalogue words it: 200 updated InterviewTemplate\n\nAuth: Session + manage_recruitment (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: InterviewTemplate primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The InterviewTemplateWrite object, all keys optional. Sending questions replaces the question list; leaving it out keeps the questions as they are.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/InterviewTemplateWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "updated InterviewTemplate",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/InterviewTemplate"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "InterviewTemplate",
     "InterviewTemplateWrite"
    ],
    "x-catalogue-group": "recruitment-interviews"
   },
   "delete": {
    "operationId": "delete_recruitment_interview_templates_id",
    "tags": [
     "Recruitment: Interviews & Scorecards"
    ],
    "summary": "Delete an interview template.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 204 No Content\n\nGuard: Repeat: a second call answers 404, because the template is gone\n\nAuth: Session + manage_recruitment (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: InterviewTemplate primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "204": {
      "description": "No Content"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-interviews"
   }
  },
  "/recruitment/interview-templates/ai-generate": {
   "post": {
    "operationId": "post_recruitment_interview_templates_ai_generate",
    "tags": [
     "Recruitment: Interviews & Scorecards"
    ],
    "summary": "Generate interview questions with the local LLM.",
    "description": "Request, as the catalogue words it: The DraftForRequisition object: { jobPostingId?:integer }, optional; the role is used as context when given.\n\nReply, as the catalogue words it: 200 { questions } (LLM-generated, HR-compliance-guarded) | 502 if generation fails\n\nAuth: Session + manage_recruitment (licensed)",
    "requestBody": {
     "description": "The DraftForRequisition object: { jobPostingId?:integer }, optional; the role is used as context when given.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DraftForRequisition"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ questions } (LLM-generated, HR-compliance-guarded)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "502": {
      "description": "if generation fails"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "DraftForRequisition"
    ],
    "x-catalogue-group": "recruitment-interviews"
   }
  },
  "/recruitment/settings/offer-gates": {
   "get": {
    "operationId": "get_recruitment_settings_offer_gates",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Read the scorecard-before-offer policy.",
    "description": "Reply, as the catalogue words it: 200 { requireScorecardsBeforeOffer:boolean, defaultValue:boolean }\n\nAuth: Session + manage_recruitment | view_recruitment | approve_offer | manage_security | source_candidates | schedule_interviews | review_executive_packet | conduct_interview (licensed)",
    "responses": {
     "200": {
      "description": "{ requireScorecardsBeforeOffer:boolean, defaultValue:boolean }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "requireScorecardsBeforeOffer": {
           "type": "boolean"
          },
          "defaultValue": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment | approve_offer | manage_security | source_candidates | schedule_interviews | review_executive_packet | conduct_interview (licensed)",
    "x-permissions-named": [
     "approve_offer",
     "conduct_interview",
     "manage_recruitment",
     "manage_security",
     "review_executive_packet",
     "schedule_interviews",
     "source_candidates",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-offers"
   },
   "put": {
    "operationId": "put_recruitment_settings_offer_gates",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Turn the scorecard-before-offer policy on or off.",
    "description": "Request, as the catalogue words it: The OfferGatePolicyWrite object: { requireScorecardsBeforeOffer:boolean } required.\n\nReply, as the catalogue words it: 200 { requireScorecardsBeforeOffer:boolean } | 400 when the value is not a boolean\n\nGuard: 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\n\nAuth: Session + manage_recruitment | manage_security (licensed)",
    "requestBody": {
     "description": "The OfferGatePolicyWrite object: { requireScorecardsBeforeOffer:boolean } required.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/OfferGatePolicyWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ requireScorecardsBeforeOffer:boolean }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "requireScorecardsBeforeOffer": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "when the value is not a boolean"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | manage_security (licensed)",
    "x-permissions-named": [
     "manage_recruitment",
     "manage_security"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "OfferGatePolicyWrite"
    ],
    "x-catalogue-group": "recruitment-offers"
   }
  },
  "/recruitment/candidates/{id}/offer-terms": {
   "put": {
    "operationId": "put_recruitment_candidates_id_offer_terms",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Set or change the terms of an offer.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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'\n\nGuard: the offer records who set the terms; that person cannot approve it\n\nAuth: Session + manage_recruitment | approve_offer (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/OfferTermsWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "'Candidate not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | approve_offer (licensed)",
    "x-permissions-named": [
     "approve_offer",
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "OfferTermsWrite"
    ],
    "x-catalogue-group": "recruitment-offers"
   }
  },
  "/recruitment/offers/pending-approval": {
   "get": {
    "operationId": "get_recruitment_offers_pending_approval",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Offers awaiting approval (the approver queue).",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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 }\n\nAuth: Session + manage_recruitment | approve_offer | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "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 }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | approve_offer | manage_finance (licensed)",
    "x-permissions-named": [
     "approve_offer",
     "manage_finance",
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-offers"
   }
  },
  "/recruitment/offers/{id}/signed-document": {
   "get": {
    "operationId": "get_recruitment_offers_id_signed_document",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Download the sealed signed offer, or its certificate of completion.",
    "description": "Request, as the catalogue words it: Query: type=certificate for the certificate; anything else returns the document.\n\nReply, as the catalogue words it: 200 application/pdf | 403 when the caller may not see offer compensation | 404 when no signed document exists yet\n\nAuth: Session + manage_recruitment | view_recruitment (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Offer primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "application/pdf"
     },
     "403": {
      "description": "when the caller may not see offer compensation"
     },
     "404": {
      "description": "when no signed document exists yet"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-offers"
   }
  },
  "/recruitment/candidates/{id}/approve-offer": {
   "post": {
    "operationId": "post_recruitment_candidates_id_approve_offer",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Approve an offer before it is sent.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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)\n\nGuard: SoD in offerService: approver must differ from offer.termsSetBy (root-admin exempt); self-approval → warn-audited RECRUITMENT_OFFER_SELF_APPROVAL_BLOCKED\n\nAuth: Session + manage_recruitment | approve_offer | manage_finance (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/OfferApprove"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "approved Offer | error status from service on failure (self-approval blocked)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Offer"
        }
       }
      }
     },
     "400": {
      "description": "code OFFER_TERMS_MISSING when no start date is set"
     },
     "403": {
      "description": "code SELF_APPROVAL"
     },
     "409": {
      "description": "code CANDIDATE_REJECTED | 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)"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | approve_offer | manage_finance (licensed)",
    "x-permissions-named": [
     "approve_offer",
     "manage_finance",
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Offer",
     "OfferApprove"
    ],
    "x-catalogue-group": "recruitment-offers"
   }
  },
  "/recruitment/candidates/{id}/reject-offer": {
   "post": {
    "operationId": "post_recruitment_candidates_id_reject_offer",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Refuse approval of an offer (this does not reject the candidate).",
    "description": "Request, as the catalogue words it: The OfferReject object: { reason?:string }.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_recruitment | approve_offer | manage_finance (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The OfferReject object: { reason?:string }.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/OfferReject"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the offer"
     },
     "404": {
      "description": "code OFFER_NOT_FOUND when the candidate has no offer"
     },
     "409": {
      "description": "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"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | approve_offer | manage_finance (licensed)",
    "x-permissions-named": [
     "approve_offer",
     "manage_finance",
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "OfferReject"
    ],
    "x-catalogue-group": "recruitment-offers"
   }
  },
  "/recruitment/candidates/{id}/send-offer": {
   "post": {
    "operationId": "post_recruitment_candidates_id_send_offer",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Generate the contract and dispatch it for signing via Zonitix Sign.",
    "description": "Request, as the catalogue words it: The OfferSend object: { offerTemplateId?:integer | null }. Generates the contract and sends it through Zonitix Sign.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_recruitment | approve_offer (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The OfferSend object: { offerTemplateId?:integer | null }. Generates the contract and sends it through Zonitix Sign.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/OfferSend"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "result { submissionId }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "candidate/template missing"
     },
     "409": {
      "description": "when the offer is not Approved | code OFFER_TERMINAL when the offer is already accepted or declined"
     },
     "502": {
      "description": "signature integration"
     },
     "503": {
      "description": "not configured"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | approve_offer (licensed)",
    "x-permissions-named": [
     "approve_offer",
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "OfferSend"
    ],
    "x-catalogue-group": "recruitment-offers"
   }
  },
  "/recruitment/candidates/{id}/mark-signed": {
   "post": {
    "operationId": "post_recruitment_candidates_id_mark_signed",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Record an offer as signed outside Zonitix Sign (paper or manual signature).",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: safe; an offer that is already accepted is returned unchanged\n\nAuth: Session + manage_recruitment (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "offer"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-offers"
   }
  },
  "/recruitment/candidates/{id}/withdraw-offer": {
   "post": {
    "operationId": "post_recruitment_candidates_id_withdraw_offer",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Withdraw a sent offer.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { offer, signingRevoked } | 404 code OFFER_NOT_FOUND | 409 code OFFER_NOT_WITHDRAWABLE unless the offer is currently Sent\n\nGuard: Repeat: a second call answers 409 with code OFFER_NOT_WITHDRAWABLE, because the offer is no longer Sent\n\nAuth: Session + manage_recruitment (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ offer, signingRevoked }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "code OFFER_NOT_FOUND"
     },
     "409": {
      "description": "code OFFER_NOT_WITHDRAWABLE unless the offer is currently Sent"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-offers"
   }
  },
  "/recruitment/candidates/{id}/offer/decline-reason": {
   "patch": {
    "operationId": "patch_recruitment_candidates_id_offer_decline_reason",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Record or clear why a candidate declined.",
    "description": "Request, as the catalogue words it: The OfferDeclineReason object: { reason:string }; empty clears it.\n\nReply, as the catalogue words it: 200 the Offer as stored (salary is null in this reply; read amounts from the candidate list)\n\nGuard: Repeat: safe; the same reason is stored again\n\nAuth: Session + manage_recruitment (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The OfferDeclineReason object: { reason:string }; empty clears it.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/OfferDeclineReason"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the Offer as stored (salary is null in this reply; read amounts from the candidate list)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Offer"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Offer",
     "OfferDeclineReason"
    ],
    "x-catalogue-group": "recruitment-offers"
   }
  },
  "/recruitment/candidates/{id}/rescind-request": {
   "post": {
    "operationId": "post_recruitment_candidates_id_rescind_request",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Request rescission of a signed offer (step one of two).",
    "description": "Request, as the catalogue words it: The OfferRescindRequest object: { reason:string } required.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: a second call answers 409 with code ALREADY_REQUESTED while the first request is waiting for approval\n\nAuth: Session + manage_recruitment | approve_offer (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The OfferRescindRequest object: { reason:string } required.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/OfferRescindRequest"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | approve_offer (licensed)",
    "x-permissions-named": [
     "approve_offer",
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "OfferRescindRequest"
    ],
    "x-catalogue-group": "recruitment-offers"
   }
  },
  "/recruitment/candidates/{id}/rescind-approve": {
   "post": {
    "operationId": "post_recruitment_candidates_id_rescind_approve",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Approve a requested rescission (step two of two).",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: a second call answers 409 with code NOT_REQUESTED, because no rescission is pending any more\n\nAuth: Session + manage_recruitment | approve_offer (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Candidate primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | approve_offer (licensed)",
    "x-permissions-named": [
     "approve_offer",
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-offers"
   }
  },
  "/recruitment/offer-templates": {
   "get": {
    "operationId": "get_recruitment_offer_templates",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "List offer templates.",
    "description": "Request, as the catalogue words it: Query: jobPostingId?\n\nReply, as the catalogue words it: 200 bare array of OfferTemplate, each with usageCount; not paged — jobPostingId is the only filter\n\nAuth: Session + manage_recruitment | view_recruitment | approve_offer (licensed)",
    "responses": {
     "200": {
      "description": "bare array of OfferTemplate, each with usageCount; not paged — jobPostingId is the only filter"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment | approve_offer (licensed)",
    "x-permissions-named": [
     "approve_offer",
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "OfferTemplate"
    ],
    "x-catalogue-group": "recruitment-offers"
   },
   "post": {
    "operationId": "post_recruitment_offer_templates",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Create an offer template.",
    "description": "Request, as the catalogue words it: The OfferTemplateWrite object: { name:string, body:string } required; jobPostingId?:integer|null (null for every requisition), supportingDocs?:object[] (at most 20).\n\nReply, as the catalogue words it: 201 OfferTemplate\n\nGuard: Repeat: not deduplicated: every call creates another template, also with the same name\n\nAuth: Session + manage_recruitment (licensed)",
    "requestBody": {
     "description": "The OfferTemplateWrite object: { name:string, body:string } required; jobPostingId?:integer|null (null for every requisition), supportingDocs?:object[] (at most 20).",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/OfferTemplateWrite"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "OfferTemplate",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/OfferTemplate"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "OfferTemplate",
     "OfferTemplateWrite"
    ],
    "x-catalogue-group": "recruitment-offers"
   }
  },
  "/recruitment/offer-templates/{id}": {
   "put": {
    "operationId": "put_recruitment_offer_templates_id",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Update an offer template.",
    "description": "Request, as the catalogue words it: The OfferTemplateWrite object, all keys optional; only the keys sent are changed.\n\nReply, as the catalogue words it: 200 updated OfferTemplate\n\nAuth: Session + manage_recruitment (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: OfferTemplate primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The OfferTemplateWrite object, all keys optional; only the keys sent are changed.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/OfferTemplateWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "updated OfferTemplate",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/OfferTemplate"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "OfferTemplate",
     "OfferTemplateWrite"
    ],
    "x-catalogue-group": "recruitment-offers"
   },
   "delete": {
    "operationId": "delete_recruitment_offer_templates_id",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Delete an offer template.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 204 No Content\n\nGuard: Repeat: a second call answers 404, because the template is gone\n\nAuth: Session + manage_recruitment (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: OfferTemplate primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "204": {
      "description": "No Content"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-offers"
   }
  },
  "/recruitment/offer-templates/ai-draft": {
   "post": {
    "operationId": "post_recruitment_offer_templates_ai_draft",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Draft an offer template with the local LLM.",
    "description": "Request, as the catalogue words it: The DraftForRequisition object: { jobPostingId?:integer }, optional; the role and its hiring manager are used as context when given.\n\nReply, as the catalogue words it: 200 { body:string } (LLM-drafted offer body) | 502 if generation fails\n\nAuth: Session + manage_recruitment (licensed)",
    "requestBody": {
     "description": "The DraftForRequisition object: { jobPostingId?:integer }, optional; the role and its hiring manager are used as context when given.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DraftForRequisition"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ body:string } (LLM-drafted offer body)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "body": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "502": {
      "description": "if generation fails"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "DraftForRequisition"
    ],
    "x-catalogue-group": "recruitment-offers"
   }
  },
  "/recruitment/offer-documents": {
   "post": {
    "operationId": "post_recruitment_offer_documents",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Upload a supporting file for an offer template.",
    "description": "Request, as the catalogue words it: multipart/form-data: file (one file, at most 10 MB); label?:string (cut to 120 characters, defaults to the file name).\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: not deduplicated: every call stores the file again under a new storedName\n\nAuth: Session + manage_recruitment (licensed)",
    "requestBody": {
     "description": "multipart/form-data: file (one file, at most 10 MB); label?:string (cut to 120 characters, defaults to the file name).",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ id:string (a UUID), type:'file', label:string, filename:string, size:integer (bytes), storedName:string }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "string",
           "description": "a UUID"
          },
          "label": {
           "type": "string"
          },
          "filename": {
           "type": "string"
          },
          "size": {
           "type": "integer",
           "description": "bytes"
          },
          "storedName": {
           "type": "string"
          }
         },
         "x-keys-not-described": [
          "type"
         ]
        }
       }
      }
     },
     "400": {
      "description": "no file, file too large, or a file type that is not allowed"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-offers"
   }
  },
  "/recruitment/offer-documents/{storedName}": {
   "get": {
    "operationId": "get_recruitment_offer_documents_storedName",
    "tags": [
     "Recruitment: Offers & Signing"
    ],
    "summary": "Download a stored offer supporting file.",
    "description": "Reply, as the catalogue words it: 200 the file | 404 'File not found'\n\nAuth: Session + manage_recruitment | view_recruitment | approve_offer (licensed)",
    "parameters": [
     {
      "name": "storedName",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "the file"
     },
     "404": {
      "description": "'File not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment | approve_offer (licensed)",
    "x-permissions-named": [
     "approve_offer",
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-offers"
   }
  },
  "/recruitment/chat": {
   "post": {
    "operationId": "post_recruitment_chat",
    "tags": [
     "Recruitment: Assistant, Referrals & Reports"
    ],
    "summary": "Recruitment assistant chat.",
    "description": "Request, as the catalogue words it: The AssistantQuery object: { query:string }.\n\nReply, as the catalogue words it: 200 { response:string } | 500 { error:string }\n\nGuard: AI context scoped to candidates/jobs THIS caller may see (no cross-user PII leak)\n\nAuth: Session + manage_recruitment | view_recruitment | source_candidates (licensed)",
    "requestBody": {
     "description": "The AssistantQuery object: { query:string }.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AssistantQuery"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ response:string }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "response": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "500": {
      "description": "{ error:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment | source_candidates (licensed)",
    "x-permissions-named": [
     "manage_recruitment",
     "source_candidates",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "AssistantQuery"
    ],
    "x-catalogue-group": "recruitment-insights"
   }
  },
  "/recruitment/chat/stream": {
   "post": {
    "operationId": "post_recruitment_chat_stream",
    "tags": [
     "Recruitment: Assistant, Referrals & Reports"
    ],
    "summary": "Recruitment assistant chat, streamed.",
    "description": "Request, as the catalogue words it: The AssistantQuery object: { query:string }, not empty.\n\nReply, as the catalogue words it: 200 application/x-ndjson stream | 400 when query is missing or empty\n\nAuth: Session + manage_recruitment | view_recruitment | source_candidates (licensed)",
    "requestBody": {
     "description": "The AssistantQuery object: { query:string }, not empty.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AssistantQuery"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "application/x-ndjson stream"
     },
     "400": {
      "description": "when query is missing or empty"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment | source_candidates (licensed)",
    "x-permissions-named": [
     "manage_recruitment",
     "source_candidates",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "AssistantQuery"
    ],
    "x-catalogue-group": "recruitment-insights"
   }
  },
  "/recruitment/chat/refine": {
   "post": {
    "operationId": "post_recruitment_chat_refine",
    "tags": [
     "Recruitment: Assistant, Referrals & Reports"
    ],
    "summary": "Refine drafted text.",
    "description": "Request, as the catalogue words it: The FeedbackRefine object: { text:string }.\n\nReply, as the catalogue words it: 200 { refinedText:string } | 500 { error:string }\n\nAuth: Session + manage_recruitment | conduct_interview | review_executive_packet (licensed)",
    "requestBody": {
     "description": "The FeedbackRefine object: { text:string }.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/FeedbackRefine"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ refinedText:string }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "refinedText": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "500": {
      "description": "{ error:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | conduct_interview | review_executive_packet (licensed)",
    "x-permissions-named": [
     "conduct_interview",
     "manage_recruitment",
     "review_executive_packet"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "FeedbackRefine"
    ],
    "x-catalogue-group": "recruitment-insights"
   }
  },
  "/recruitment/referrals": {
   "get": {
    "operationId": "get_recruitment_referrals",
    "tags": [
     "Recruitment: Assistant, Referrals & Reports"
    ],
    "summary": "List your own employee referrals.",
    "description": "Request, as the catalogue words it: No query parameters; not paged.\n\nReply, as the catalogue words it: 200 [ Referral ]: the caller's own, newest first, each with its Candidate\n\nGuard: only the signed-in user's own referrals; screening results and internal notes about the candidate are not included\n\nAuth: Session + view_referrals | manage_referrals (licensed)",
    "responses": {
     "200": {
      "description": "[ Referral ]: the caller's own, newest first, each with its Candidate",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/Referral"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_referrals | manage_referrals (licensed)",
    "x-permissions-named": [
     "manage_referrals",
     "view_referrals"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Referral"
    ],
    "x-catalogue-group": "recruitment-insights"
   },
   "post": {
    "operationId": "post_recruitment_referrals",
    "tags": [
     "Recruitment: Assistant, Referrals & Reports"
    ],
    "summary": "Submit a referral with a resume (multipart).",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: scoped to the signed-in user. Repeat: a second referral of the same email for the same role answers 409 and creates nothing\n\nAuth: Session + view_referrals | manage_referrals (licensed)",
    "requestBody": {
     "description": "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.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "Referral (creates a Referral-sourced Candidate; AI-screens if résumé attached)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Referral"
        }
       }
      }
     },
     "404": {
      "description": "code JOB_NOT_FOUND"
     },
     "409": {
      "description": "code CANDIDATE_ALREADY_EXISTS when that person is already a candidate for the role"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_referrals | manage_referrals (licensed)",
    "x-permissions-named": [
     "manage_referrals",
     "view_referrals"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Referral"
    ],
    "x-catalogue-group": "recruitment-insights"
   }
  },
  "/recruitment/analytics/pipeline": {
   "get": {
    "operationId": "get_recruitment_analytics_pipeline",
    "tags": [
     "Recruitment: Assistant, Referrals & Reports"
    ],
    "summary": "Pipeline analytics.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { perStage, bottleneck:{ stage, avgDays } | null, timeToHireDays, timeToHireSamples, stalled, avgInStageDays, agingDays, eventCount }\n\nGuard: figures cover only the candidates the caller may see\n\nAuth: Session + manage_recruitment | view_recruitment (licensed)",
    "responses": {
     "200": {
      "description": "{ perStage, bottleneck:{ stage, avgDays } | null, timeToHireDays, timeToHireSamples, stalled, avgInStageDays, agingDays, eventCount }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "bottleneck": {
           "type": [
            "object",
            "null"
           ],
           "x-keys-not-described": [
            "stage",
            "avgDays"
           ]
          }
         },
         "x-keys-not-described": [
          "perStage",
          "timeToHireDays",
          "timeToHireSamples",
          "stalled",
          "avgInStageDays",
          "agingDays",
          "eventCount"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-insights"
   }
  },
  "/recruitment/analytics/provisioning-impact/{id}": {
   "get": {
    "operationId": "get_recruitment_analytics_provisioning_impact_id",
    "tags": [
     "Recruitment: Assistant, Referrals & Reports"
    ],
    "summary": "Cost/provisioning impact of a hire.",
    "description": "Request, as the catalogue words it: :id is the job posting id. No query parameters.\n\nReply, as the catalogue words it: 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\n\nAuth: Session + manage_recruitment | view_finance (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_finance (licensed)",
    "x-permissions-named": [
     "manage_recruitment",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-insights"
   }
  },
  "/recruitment/reports/hiring": {
   "get": {
    "operationId": "get_recruitment_reports_hiring",
    "tags": [
     "Recruitment: Assistant, Referrals & Reports"
    ],
    "summary": "Consolidated hiring report: funnel, stage conversion, time to hire, source effectiveness and offers.",
    "description": "Request, as the catalogue words it: Query: from?, to? (dates).\n\nReply, as the catalogue words it: 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\n\nGuard: figures cover only the candidates the caller may see\n\nAuth: Session + manage_recruitment | view_recruitment | review_executive_packet (licensed)",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "generatedAt": {
           "type": "string",
           "format": "date-time"
          },
          "window": {
           "type": [
            "object",
            "null"
           ],
           "properties": {
            "from": {
             "type": [
              "string",
              "null"
             ]
            },
            "to": {
             "type": [
              "string",
              "null"
             ]
            }
           },
           "description": "null for all time"
          },
          "funnel": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "stage": {
              "type": "string"
             },
             "count": {
              "type": "integer"
             }
            }
           }
          },
          "totals": {
           "type": "object",
           "x-keys-not-described": [
            "total",
            "hired",
            "rejected",
            "hiredThisMonth",
            "hiredLastMonth"
           ],
           "description": "integers"
          },
          "conversion": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "from": {
              "type": "string"
             },
             "to": {
              "type": "string"
             },
             "reached": {
              "type": "integer"
             },
             "advanced": {
              "type": "integer"
             },
             "rate": {
              "type": "integer",
              "description": "a whole percentage"
             }
            }
           }
          },
          "efficiency": {
           "type": "object",
           "x-keys-not-described": [
            "timeToHireDays",
            "timeToHireSamples",
            "bottleneck",
            "avgInStageDays",
            "stalled",
            "agingDays"
           ]
          },
          "openRequisitions": {
           "type": "integer"
          }
         },
         "x-keys-not-described": [
          "sources",
          "offers"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment | review_executive_packet (licensed)",
    "x-permissions-named": [
     "manage_recruitment",
     "review_executive_packet",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-insights"
   }
  },
  "/recruitment/insights": {
   "get": {
    "operationId": "get_recruitment_insights",
    "tags": [
     "Recruitment: Assistant, Referrals & Reports"
    ],
    "summary": "LLM-generated pipeline insights.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { insights:array, generatedAt, aiAvailable:boolean } — aiAvailable is false and insights is empty when the local model returned nothing\n\nGuard: 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\n\nAuth: Session + manage_recruitment | view_recruitment (licensed)",
    "responses": {
     "200": {
      "description": "{ insights:array, generatedAt, aiAvailable:boolean } — aiAvailable is false and insights is empty when the local model returned nothing",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "insights": {
           "type": "array"
          },
          "aiAvailable": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "generatedAt"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment | view_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-insights"
   }
  },
  "/recruitment/careers-settings": {
   "get": {
    "operationId": "get_recruitment_careers_settings",
    "tags": [
     "Recruitment: Assistant, Referrals & Reports"
    ],
    "summary": "Read the careers-page content and feature switches.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Session + manage_recruitment (licensed)",
    "responses": {
     "200": {
      "description": "{ intro, whereWeWork, teamSize, replyTime, topBenefit, linkedinUrl, privacyContact: each string; talentPoolEnabled:boolean, alertsEnabled:boolean } — text fields default to an empty string, both switches to true",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "alertsEnabled": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "intro",
          "whereWeWork",
          "teamSize",
          "replyTime",
          "topBenefit",
          "linkedinUrl",
          "privacyContact"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-insights"
   },
   "put": {
    "operationId": "put_recruitment_careers_settings",
    "tags": [
     "Recruitment: Assistant, Referrals & Reports"
    ],
    "summary": "Update the careers-page content and feature switches.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: changed fields are audited; safe to repeat — a call that changes nothing saves nothing and writes no audit entry\n\nAuth: Session + manage_recruitment (licensed)",
    "requestBody": {
     "description": "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.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "the saved settings (all fields)"
     },
     "400": {
      "description": "code BAD_URL when linkedinUrl is not an https:// address | code BAD_PRIVACY_CONTACT when privacyContact is neither an email address nor an https:// link"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment (licensed)",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-insights"
   }
  },
  "/recruitment/public/jobs": {
   "get": {
    "operationId": "get_recruitment_public_jobs",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "Public job board listing.",
    "description": "Request, as the catalogue words it: No query parameters; not paged.\n\nReply, as the catalogue words it: 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) }\n\nAuth: Public (rate-limited, licensed)",
    "responses": {
     "200": {
      "description": "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) }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited, licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/recruitment/public/jobs/{id}": {
   "get": {
    "operationId": "get_recruitment_public_jobs_id",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "One public job posting (numeric id).",
    "description": "Reply, as the catalogue words it: 200 the posting | 404 { error:string } when the position is no longer open — same fields as one row of the list\n\nAuth: Public (rate-limited, licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: JobPosting primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "the posting"
     },
     "404": {
      "description": "{ error:string } when the position is no longer open — same fields as one row of the list",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited, licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/recruitment/public/jobs/jsonld": {
   "get": {
    "operationId": "get_recruitment_public_jobs_jsonld",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "Open roles as schema.org JobPosting JSON-LD.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Public (rate-limited, licensed)",
    "responses": {
     "200": {
      "description": "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"
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited, licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/recruitment/public/jobs/{id}/jsonld": {
   "get": {
    "operationId": "get_recruitment_public_jobs_id_jsonld",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "One open role as schema.org JobPosting JSON-LD.",
    "description": "Reply, as the catalogue words it: 200 JSON-LD | 404 { error:string } when the position is no longer open\n\nAuth: Public (rate-limited, licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: JobPosting primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "JSON-LD"
     },
     "404": {
      "description": "{ error:string } when the position is no longer open",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited, licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/recruitment/public/jobs/indeed.xml": {
   "get": {
    "operationId": "get_recruitment_public_jobs_indeed_xml",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "Job feed in the Indeed XML format.",
    "description": "Reply, as the catalogue words it: 200 application/xml: one feed holding every Published role (the same roles as GET /public/jobs); not paged\n\nAuth: Public (rate-limited, licensed)",
    "responses": {
     "200": {
      "description": "application/xml: one feed holding every Published role (the same roles as GET /public/jobs); not paged"
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited, licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/recruitment/public/jobs/linkedin.xml": {
   "get": {
    "operationId": "get_recruitment_public_jobs_linkedin_xml",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "Job feed in the LinkedIn XML format.",
    "description": "Reply, as the catalogue words it: 200 application/xml: one feed holding every Published role (the same roles as GET /public/jobs); not paged\n\nAuth: Public (rate-limited, licensed)",
    "responses": {
     "200": {
      "description": "application/xml: one feed holding every Published role (the same roles as GET /public/jobs); not paged"
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited, licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/recruitment/public/careers": {
   "get": {
    "operationId": "get_recruitment_public_careers",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "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.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Public (rate-limited, licensed)",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "companyName": {
           "type": "string"
          },
          "logoUrl": {
           "type": [
            "string",
            "null"
           ]
          },
          "intro": {
           "type": [
            "string",
            "null"
           ]
          },
          "facts": {
           "type": "object",
           "x-keys-not-described": [
            "whereWeWork",
            "teamSize",
            "replyTime",
            "topBenefit"
           ]
          },
          "linkedinUrl": {
           "type": [
            "string",
            "null"
           ]
          },
          "privacyContact": {
           "type": [
            "string",
            "null"
           ]
          },
          "talentPoolEnabled": {
           "type": "boolean"
          },
          "alertsEnabled": {
           "type": "boolean"
          },
          "departments": {
           "type": "array",
           "items": {
            "type": "string"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited, licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/recruitment/public/apply/{jobId}": {
   "post": {
    "operationId": "post_recruitment_public_apply_jobId",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "Submit an application from the careers site.",
    "description": "Request, as the catalogue words it: 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)\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Public (rate-limited, also per email, licensed)",
    "parameters": [
     {
      "name": "jobId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: JobPosting primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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)",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ success:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "{ error:string } 'This position is no longer open.' — any role that is not Published",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited, also per email, licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/recruitment/public/apply-general": {
   "post": {
    "operationId": "post_recruitment_public_apply_general",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "Submit a general application into the talent pool, with no specific role.",
    "description": "Request, as the catalogue words it: Same multipart fields as POST /recruitment/public/apply/:jobId.\n\nReply, as the catalogue words it: 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\n\nGuard: lands in the single General applications requisition; a repeat with the same email answers 201 and creates nothing\n\nAuth: Public (rate-limited, also per email, licensed)",
    "requestBody": {
     "description": "Same multipart fields as POST /recruitment/public/apply/:jobId.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ success:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "{ error:string } as for a role application",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "{ error:string } when the talent pool is switched off in the careers settings",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited, also per email, licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/recruitment/public/job-alerts": {
   "post": {
    "operationId": "post_recruitment_public_job_alerts",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "Subscribe an email address to job alerts (double opt-in).",
    "description": "Request, as the catalogue words it: The JobAlertSubscribe object: { email:string, consent:true } required; department?:string, locale?:string. email is cut to 200 characters, department to 120, locale to 8.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Public (rate-limited, also per email, licensed)",
    "requestBody": {
     "description": "The JobAlertSubscribe object: { email:string, consent:true } required; department?:string, locale?:string. email is cut to 200 characters, department to 120, locale to 8.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/JobAlertSubscribe"
       }
      }
     }
    },
    "responses": {
     "202": {
      "description": "{ success:true } for every valid request",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "code BAD_EMAIL | code CONSENT_REQUIRED"
     },
     "404": {
      "description": "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"
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited, also per email, licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "JobAlertSubscribe"
    ],
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/recruitment/public/job-alerts/confirm": {
   "post": {
    "operationId": "post_recruitment_public_job_alerts_confirm",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "Confirm a job-alert subscription from the emailed link.",
    "description": "Request, as the catalogue words it: The JobAlertToken object: { token:string }.\n\nReply, as the catalogue words it: 200 { confirmed:true } | 404 { error:string } when the link is invalid or expired. Confirming twice does not change the stored confirmation time\n\nGuard: Repeat: safe; a second call answers 200 and keeps the first confirmation time, and writes another JOB_ALERT_CONFIRMED audit entry\n\nAuth: Public (rate-limited, licensed)",
    "requestBody": {
     "description": "The JobAlertToken object: { token:string }.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/JobAlertToken"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ confirmed:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "confirmed": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "{ error:string } when the link is invalid or expired. Confirming twice does not change the stored confirmation time",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited, licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "JobAlertToken"
    ],
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/recruitment/public/job-alerts/unsubscribe": {
   "post": {
    "operationId": "post_recruitment_public_job_alerts_unsubscribe",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "Withdraw a job-alert subscription.",
    "description": "Request, as the catalogue words it: The JobAlertToken object: { token:string }.\n\nReply, as the catalogue words it: 200 { unsubscribed:true, found:boolean } — the subscription row is deleted; a repeat answers 200 with found:false\n\nGuard: Repeat: safe; the subscription is deleted by the first call and a second answers 200 with found:false\n\nAuth: Public (rate-limited, licensed)",
    "requestBody": {
     "description": "The JobAlertToken object: { token:string }.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/JobAlertToken"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ unsubscribed:true, found:boolean } — the subscription row is deleted; a repeat answers 200 with found:false",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "unsubscribed": {
           "type": "boolean"
          },
          "found": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited, licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "JobAlertToken"
    ],
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/recruitment/public/branding": {
   "get": {
    "operationId": "get_recruitment_public_branding",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "Company name, logo and brand colour for the candidate portal.",
    "description": "Reply, as the catalogue words it: 200 { companyName:string, logoUrl:string|null, brandColor:string }\n\nAuth: Public (rate-limited)",
    "responses": {
     "200": {
      "description": "{ companyName:string, logoUrl:string|null, brandColor:string }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "companyName": {
           "type": "string"
          },
          "logoUrl": {
           "type": [
            "string",
            "null"
           ]
          },
          "brandColor": {
           "type": "string"
          }
         }
        }
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/recruitment/public/self-schedule/{token}": {
   "get": {
    "operationId": "get_recruitment_public_self_schedule_token",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "Candidate self-scheduling page data, addressed by a one-time token.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Public (rate-limited)",
    "parameters": [
     {
      "name": "token",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "recruitment-public"
   },
   "post": {
    "operationId": "post_recruitment_public_self_schedule_token",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "Candidate books one of the offered times.",
    "description": "Request, as the catalogue words it: The SelfScheduleChoice object: { chosen:string (date-time) }: one of the times offered by GET on the same path.\n\nReply, as the catalogue words it: 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)\n\nGuard: Repeat: a second call answers 409 with code ALREADY_SCHEDULED once a time is booked\n\nAuth: Public (rate-limited)",
    "parameters": [
     {
      "name": "token",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "requestBody": {
     "description": "The SelfScheduleChoice object: { chosen:string (date-time) }: one of the times offered by GET on the same path.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/SelfScheduleChoice"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ 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)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "scheduledAt": {
           "type": "string",
           "format": "date-time"
          }
         }
        }
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-typed-objects": [
     "SelfScheduleChoice"
    ],
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/recruitment/public/portal/{token}": {
   "get": {
    "operationId": "get_recruitment_public_portal_token",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "Candidate portal, addressed by a one-time token.",
    "description": "Reply, as the catalogue words it: 200 portal data | 404 { error:string } for an unknown or malformed token | 403 { error:string } for an expired one\n\nAuth: Public (rate-limited)",
    "parameters": [
     {
      "name": "token",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "portal data"
     },
     "403": {
      "description": "{ error:string } for an expired one",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "{ error:string } for an unknown or malformed token",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/recruitment/public/portal/{token}/document/{docId}": {
   "get": {
    "operationId": "get_recruitment_public_portal_token_document_docId",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "Candidate downloads a supporting file attached to their offer.",
    "description": "Reply, as the catalogue words it: 200 the file | 404 unknown token or document | 410 'This offer is no longer available.'\n\nGuard: the file must be one of that offer's supporting documents\n\nAuth: Public (rate-limited)",
    "parameters": [
     {
      "name": "token",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     },
     {
      "name": "docId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "the file"
     },
     "404": {
      "description": "unknown token or document"
     },
     "410": {
      "description": "'This offer is no longer available.'"
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/recruitment/public/portal/{token}/hardware": {
   "post": {
    "operationId": "post_recruitment_public_portal_token_hardware",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "Candidate submits their hardware selection.",
    "description": "Request, as the catalogue words it: Hardware selection object (stored verbatim as candidate.onboardingData.hardwareSelection).\n\nReply, as the catalogue words it: 200 { success:true } | 400 'Selection is only available for active or accepted offers.' | 404 { error:string } for a malformed token\n\nGuard: public + rate-limited; token-authed (onboardingToken); offer must be Sent/Accepted; may trigger onboarding\n\nAuth: Public (rate-limited)",
    "parameters": [
     {
      "name": "token",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "requestBody": {
     "description": "Hardware selection object (stored verbatim as candidate.onboardingData.hardwareSelection).",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "'Selection is only available for active or accepted offers.'"
     },
     "404": {
      "description": "{ error:string } for a malformed token",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/recruitment/webhooks/docuseal": {
   "post": {
    "operationId": "post_recruitment_webhooks_docuseal",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "Zonitix Sign completion callback (path name retained for compatibility).",
    "description": "Request, as the catalogue words it: { event_type, data:{ id, ... } } — Zonitix Sign completion callback.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Signed request (HMAC, see Inbound Webhooks)",
    "requestBody": {
     "description": "{ event_type, data:{ id, ... } } — Zonitix Sign completion callback.",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "data": {
          "type": "object",
          "x-keys-not-described": [
           "id"
          ]
         }
        },
        "x-keys-not-described": [
         "event_type"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "'OK'"
     },
     "400": {
      "description": "text 'Invalid signature' for a missing, stale or mismatched signature (the service answers 401; the gateway relays it as 400, with the body unchanged) | 'Malformed payload'"
     },
     "503": {
      "description": "text 'Webhook not configured' if the signing secret is unset in production"
     }
    },
    "security": [
     {
      "namedInXAuth": []
     }
    ],
    "x-auth": "Signed request (HMAC, see Inbound Webhooks)",
    "x-permissions-named": [],
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/recruitment/webhooks/inbound-email": {
   "post": {
    "operationId": "post_recruitment_webhooks_inbound_email",
    "tags": [
     "Recruitment: Careers Site, Candidate Portal & Webhooks"
    ],
    "summary": "Inbound-email provider callback that records a candidate reply on their thread.",
    "description": "Request, as the catalogue words it: { 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\n\nReply, as the catalogue words it: 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 }\n\nGuard: 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\n\nAuth: Shared secret (rate-limited)",
    "requestBody": {
     "description": "{ 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",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok:true, recorded:true, candidateId:integer, messageId:integer } | { ok:true, ignored:'not_configured'|'bad_secret'|'no_token'|'no_match' } | { ok:true, deduped:true, candidateId:integer }",
      "content": {
       "application/json": {
        "schema": {
         "anyOf": [
          {
           "type": "object",
           "properties": {
            "ok": {
             "type": "boolean"
            },
            "recorded": {
             "type": "boolean"
            },
            "candidateId": {
             "type": "integer"
            },
            "messageId": {
             "type": "integer"
            }
           }
          },
          {
           "type": "object",
           "properties": {
            "ok": {
             "type": "boolean"
            },
            "ignored": {
             "type": "string",
             "enum": [
              "not_configured",
              "bad_secret",
              "no_token",
              "no_match"
             ]
            }
           }
          },
          {
           "type": "object",
           "properties": {
            "ok": {
             "type": "boolean"
            },
            "deduped": {
             "type": "boolean"
            },
            "candidateId": {
             "type": "integer"
            }
           }
          }
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "namedInXAuth": []
     }
    ],
    "x-auth": "Shared secret (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "recruitment-public"
   }
  },
  "/parser/parse": {
   "post": {
    "operationId": "post_parser_parse",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "Parse an uploaded invoice PDF into structured fields.",
    "description": "Request, as the catalogue words it: multipart form: file (UploadFile) required — pdf/docx/png/jpg/jpeg/xlsx/txt, at most 10 MB. No JSON body.\n\nReply, as the catalogue words it: 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\n\nGuard: file_safety validation must pass. Repeat: the same file sent again answers 409 and stores nothing\n\nAuth: Session + manage_finance | submit_invoice (licensed)",
    "requestBody": {
     "description": "multipart form: file (UploadFile) required — pdf/docx/png/jpg/jpeg/xlsx/txt, at most 10 MB. No JSON body.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{success, document_id, displayId, approvalStatus, holdReasons, currency, filename, text, extracted_fields{total,date,renewal_date,vendor,category,status,user_id}, method}",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "unsupported extension, content that does not match the extension, or over 10 MB"
     },
     "409": {
      "description": "the same file was already uploaded, or the same vendor and invoice number already exist"
     },
     "500": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | submit_invoice (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "submit_invoice"
    ],
    "x-licensed": true,
    "x-catalogue-group": "parser"
   }
  },
  "/parser/my-documents": {
   "get": {
    "operationId": "get_parser_my_documents",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "Documents you uploaded yourself.",
    "description": "Request, as the catalogue words it: Query: limit (default 50, clamped to 1–200).\n\nReply, as the catalogue words it: 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\n\nGuard: only documents the caller uploaded\n\nAuth: Session + view_finance | manage_finance | submit_invoice (licensed)",
    "parameters": [
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 50,
       "minimum": 1,
       "maximum": 200
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "items": {
           "type": "array",
           "items": {
            "type": "object"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | submit_invoice (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "submit_invoice",
     "view_finance"
    ],
    "x-licensed": true,
    "x-paging-parameters": [
     "limit"
    ],
    "x-catalogue-group": "parser"
   }
  },
  "/parser/my-documents/{doc_id}/timeline": {
   "get": {
    "operationId": "get_parser_my_documents_doc_id_timeline",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "History of one invoice the caller may see.",
    "description": "Reply, as the catalogue words it: 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'\n\nGuard: a self-service submitter can read only their own invoice; the answer is the same 404 either way\n\nAuth: Session + view_finance | manage_finance | submit_invoice (licensed)",
    "parameters": [
     {
      "name": "doc_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "docId": {
           "type": "integer"
          },
          "displayId": {
           "type": "string"
          },
          "approvalStatus": {
           "type": "string"
          },
          "steps": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "step": {
              "type": "string"
             },
             "label": {
              "type": "string"
             },
             "at": {
              "type": "string",
              "format": "date-time"
             },
             "by": {
              "type": "string"
             },
             "reason": {
              "type": "string",
              "description": "on a rejection"
             }
            }
           }
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "'Document not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | submit_invoice (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "submit_invoice",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "parser"
   }
  },
  "/parser/invoices": {
   "get": {
    "operationId": "get_parser_invoices",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "Paginated, filterable invoice ledger.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "parameters": [
     {
      "name": "page",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 1,
       "minimum": 1
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 25,
       "minimum": 1,
       "maximum": 200
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "sort_by",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "default": "date"
      }
     },
     {
      "name": "sort_dir",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "default": "desc"
      }
     },
     {
      "name": "date_from",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "date_to",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "vendor",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "category",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "status",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "cost_center",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "approval_status",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "search",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/InvoicePage"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-paging-parameters": [
     "limit",
     "page"
    ],
    "x-typed-objects": [
     "Invoice",
     "InvoicePage"
    ],
    "x-catalogue-group": "parser"
   }
  },
  "/parser/invoices/export": {
   "get": {
    "operationId": "get_parser_invoices_export",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "Export the ledger as CSV.",
    "description": "Request, as the catalogue words it: Query: date_from, date_to, vendor, category, status, cost_center, approval_status, search.\n\nReply, as the catalogue words it: 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 }\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "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": {
      "description": "{ detail:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Invoice"
    ],
    "x-catalogue-group": "parser"
   }
  },
  "/parser/invoices/export.xlsx": {
   "get": {
    "operationId": "get_parser_invoices_export_xlsx",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "Export the ledger as Excel.",
    "description": "Request, as the catalogue words it: Query: date_from, date_to, vendor, category, status, cost_center, approval_status, search.\n\nReply, as the catalogue words it: 200 .xlsx attachment zonitix-invoices.xlsx, same filters as the CSV export; not paged | 500 { detail:string }\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": ".xlsx attachment zonitix-invoices.xlsx, same filters as the CSV export; not paged"
     },
     "500": {
      "description": "{ detail:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "parser"
   }
  },
  "/parser/invoices/export.pdf": {
   "get": {
    "operationId": "get_parser_invoices_export_pdf",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "Export the ledger as PDF.",
    "description": "Request, as the catalogue words it: Query: date_from, date_to, vendor, category, status, cost_center, approval_status, search.\n\nReply, as the catalogue words it: 200 application/pdf attachment zonitix-invoice-ledger.pdf, same filters as the CSV export; not paged | 500 { detail:string }\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "application/pdf attachment zonitix-invoice-ledger.pdf, same filters as the CSV export; not paged"
     },
     "500": {
      "description": "{ detail:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "parser"
   }
  },
  "/parser/invoices/import": {
   "post": {
    "operationId": "post_parser_invoices_import",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "Bulk-import invoices.",
    "description": "Request, as the catalogue words it: multipart form: file (UploadFile) required — CSV. No JSON body.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_finance (licensed)",
    "requestBody": {
     "description": "multipart form: file (UploadFile) required — CSV. No JSON body.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "an InvoiceImportSummary { imported:integer, skipped:integer, errors:string[] (the first 50), currencyDefaulted:integer, amountAmbiguous:integer, dateHeld:integer, duplicatesSkipped:integer, duplicatesHeld:integer }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/InvoiceImportSummary"
        }
       }
      }
     },
     "400": {
      "description": "unsafe file"
     },
     "500": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "InvoiceImportSummary"
    ],
    "x-catalogue-group": "parser"
   }
  },
  "/parser/stats": {
   "get": {
    "operationId": "get_parser_stats",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "Parsing and spend statistics.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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 }\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "{ 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 }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "recentInvoices": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "id",
             "db_id",
             "vendor",
             "amount",
             "currency",
             "amountBase",
             "costCenter",
             "date",
             "renewalDate",
             "category",
             "status"
            ]
           }
          }
         },
         "x-keys-not-described": [
          "baseCurrency",
          "totalSpend",
          "monthlyBudget",
          "vendorCount",
          "pendingCount",
          "monthlyTrend",
          "forecast",
          "categoryBreakdown",
          "topVendors",
          "upcomingRenewals",
          "awsBilling",
          "azureBilling",
          "awsInvoiceTotal",
          "azureInvoiceTotal",
          "awsReconcileSpend",
          "azureReconcileSpend",
          "cloudBillingByProvider",
          "cloudPrimaryProvider"
         ]
        }
       }
      }
     },
     "500": {
      "description": "{ detail:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "parser"
   }
  },
  "/parser/health": {
   "get": {
    "operationId": "get_parser_health",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "Is the document parser answering. Add ?ready=1 to test its database as well.",
    "description": "Request, as the catalogue words it: Query: ready? (1 or true).\n\nReply, as the catalogue words it: 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 }\n\nGuard: Any signed-in person; the gateway requires a session.\n\nAuth: Session",
    "responses": {
     "200": {
      "description": "{ 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' }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "results": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "name": {
              "type": "string"
             }
            },
            "x-keys-not-described": [
             "status"
            ]
           }
          },
          "rate": {
           "type": "number"
          }
         },
         "x-keys-not-described": [
          "status",
          "service"
         ]
        }
       }
      }
     },
     "503": {
      "description": "{ status:'degraded', service:'document-parser', db:'down', reason:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-catalogue-group": "parser"
   }
  },
  "/parser/finance/documents/{doc_id}": {
   "get": {
    "operationId": "get_parser_finance_documents_doc_id",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "One invoice in full.",
    "description": "Reply, as the catalogue words it: 200 the InvoiceDetail object | 404 'Document not found'\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "parameters": [
     {
      "name": "doc_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "the InvoiceDetail object",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/InvoiceDetail"
        }
       }
      }
     },
     "404": {
      "description": "'Document not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "InvoiceDetail"
    ],
    "x-catalogue-group": "parser"
   }
  },
  "/parser/finance/documents/{doc_id}/file": {
   "get": {
    "operationId": "get_parser_finance_documents_doc_id_file",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "The original uploaded file of an invoice.",
    "description": "Reply, as the catalogue words it: 200 the file | 404 'No original file stored for this document'\n\nAuth: Session + view_finance | manage_finance | submit_invoice (licensed)",
    "parameters": [
     {
      "name": "doc_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "the file"
     },
     "404": {
      "description": "'No original file stored for this document'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | submit_invoice (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "submit_invoice",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "parser"
   }
  },
  "/parser/finance/documents/{doc_id}/review": {
   "patch": {
    "operationId": "patch_parser_finance_documents_doc_id_review",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "Correct the extracted fields of an invoice held for review, and optionally release it.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: a caller without manage_finance can correct only an invoice they uploaded\n\nAuth: Session + manage_finance | submit_invoice (licensed)",
    "parameters": [
     {
      "name": "doc_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/InvoiceReview"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success:true, approvalStatus, changes }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "approvalStatus",
          "changes"
         ]
        }
       }
      }
     },
     "400": {
      "description": "an amount that cannot be read, or, for a caller who holds manage_finance, a currency with no exchange rate set"
     },
     "403": {
      "description": "not your upload"
     },
     "404": {
      "description": "not found"
     },
     "409": {
      "description": "the invoice can no longer be changed, or it cannot be released yet"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | submit_invoice (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "submit_invoice"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "InvoiceReview"
    ],
    "x-catalogue-group": "parser"
   }
  },
  "/parser/finance/documents/{doc_id}/allocation": {
   "patch": {
    "operationId": "patch_parser_finance_documents_doc_id_allocation",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "Allocate a document to cost centres.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_finance (licensed)",
    "parameters": [
     {
      "name": "doc_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/InvoiceAllocation"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success:true, id:integer }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          },
          "id": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "a GL code that is not in the chart of accounts"
     },
     "404": {
      "description": "not found"
     },
     "409": {
      "description": "if Submitted/Approved/Paid (frozen)"
     },
     "500": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "InvoiceAllocation"
    ],
    "x-catalogue-group": "parser"
   }
  },
  "/parser/finance/documents/{doc_id}/{action}": {
   "post": {
    "operationId": "post_parser_finance_documents_doc_id_action",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "Approve or reject a document (approval flow with separation of duties).",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + per-action permission, see guard (licensed)",
    "parameters": [
     {
      "name": "doc_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     },
     {
      "name": "action",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Typed from the handler's signature: declared str in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/InvoiceAction"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success:true, approvalStatus:string }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          },
          "approvalStatus": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "illegal transition, a reject without a reason, or a budget override without a written reason"
     },
     "403": {
      "description": "SoD violation"
     },
     "404": {
      "description": "unknown action or doc"
     },
     "409": {
      "description": "budget-exceeded or illegal transition"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + per-action permission, see guard (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "InvoiceAction"
    ],
    "x-catalogue-group": "parser"
   }
  },
  "/parser/finance/documents/bulk": {
   "post": {
    "operationId": "post_parser_finance_documents_bulk",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "Apply one action to many invoices: any single-invoice action, allocate or delete.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + per-action permission, see guard (licensed)",
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/InvoiceBulkAction"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "a BulkOutcome { action:string, ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { id, ok:boolean, error?:string, approvalStatus?:string }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/BulkOutcome"
        }
       }
      }
     },
     "400": {
      "description": "unknown action, empty list, or more than 500 ids"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + per-action permission, see guard (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "BulkOutcomeItem",
     "BulkOutcome",
     "InvoiceAllocation",
     "InvoiceBulkAction"
    ],
    "x-catalogue-group": "parser"
   }
  },
  "/parser/finance/rematch/{doc_id}": {
   "post": {
    "operationId": "post_parser_finance_rematch_doc_id",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "Re-run purchase-order matching for one invoice.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { success:true, matchStatus:string } | 404 'Document not found'\n\nAuth: Session + manage_finance (licensed)",
    "parameters": [
     {
      "name": "doc_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{ success:true, matchStatus:string }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          },
          "matchStatus": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "'Document not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "parser"
   }
  },
  "/parser/documents/{doc_id}": {
   "delete": {
    "operationId": "delete_parser_documents_doc_id",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "Delete a document.",
    "description": "Request, as the catalogue words it: No body. doc_id path param (int).\n\nReply, as the catalogue words it: 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\n\nGuard: 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'\n\nAuth: Session + manage_finance | submit_invoice",
    "parameters": [
     {
      "name": "doc_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{ success:true, message:string }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          },
          "message": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "403": {
      "description": "not your document"
     },
     "404": {
      "description": "not found"
     },
     "409": {
      "description": "if Submitted/Approved/Paid, if it has approval or ledger history, or if it is still referenced elsewhere"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | submit_invoice",
    "x-permissions-named": [
     "manage_finance",
     "submit_invoice"
    ],
    "x-catalogue-group": "parser"
   }
  },
  "/parser/documents/{doc_id}/status": {
   "patch": {
    "operationId": "patch_parser_documents_doc_id_status",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "Update a document status.",
    "description": "Request, as the catalogue words it: The DocumentStatusWrite object: { status:string } required. doc_id is an integer in the path.\n\nReply, as the catalogue words it: 200 { success:true, message:string } | 400 missing status | 404 not found | 403 IDOR or controlled target\n\nGuard: 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\n\nAuth: Session + manage_finance",
    "parameters": [
     {
      "name": "doc_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "The DocumentStatusWrite object: { status:string } required. doc_id is an integer in the path.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DocumentStatusWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success:true, message:string }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          },
          "message": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "missing status"
     },
     "403": {
      "description": "IDOR or controlled target"
     },
     "404": {
      "description": "not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-typed-objects": [
     "DocumentStatusWrite"
    ],
    "x-catalogue-group": "parser"
   }
  },
  "/parser/screen-resume": {
   "post": {
    "operationId": "post_parser_screen_resume",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "Extract structured data from a resume.",
    "description": "Request, as the catalogue words it: 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 '[]').\n\nReply, as the catalogue words it: 200 {success:true, match_score, score_source, ai_summary, raw_text_preview} | 400 unsupported/unsafe file | 500\n\nAuth: Session + view_recruitment | manage_recruitment",
    "requestBody": {
     "description": "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 '[]').",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{success:true, match_score, score_source, ai_summary, raw_text_preview}",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "match_score",
          "score_source",
          "ai_summary",
          "raw_text_preview"
         ]
        }
       }
      }
     },
     "400": {
      "description": "unsupported/unsafe file"
     },
     "500": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_recruitment | manage_recruitment",
    "x-permissions-named": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-catalogue-group": "parser"
   }
  },
  "/parser/extract-text": {
   "post": {
    "operationId": "post_parser_extract_text",
    "tags": [
     "Finance & Document Parser"
    ],
    "summary": "Extract raw text from a document. Internal: used between Zonitix services, not callable with a session or API token.",
    "description": "Request, as the catalogue words it: multipart form: file (UploadFile) required — pdf/docx/txt only. No JSON body.\n\nReply, as the catalogue words it: 404 through the gateway — the gateway refuses this path for every session and API token\n\nGuard: service-to-service only: the gateway blocks it by name, and the service itself rejects a call that arrived through the gateway\n\nAuth: Internal only",
    "requestBody": {
     "description": "multipart form: file (UploadFile) required — pdf/docx/txt only. No JSON body.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "404": {
      "description": "through the gateway — the gateway refuses this path for every session and API token"
     }
    },
    "security": [
     {
      "namedInXAuth": []
     }
    ],
    "x-auth": "Internal only",
    "x-permissions-named": [],
    "x-catalogue-group": "parser"
   }
  },
  "/parser/finance/insights": {
   "get": {
    "operationId": "get_parser_finance_insights",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Local-AI spend insights.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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 }\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "duplicates": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "vendor",
             "amount",
             "daysApart",
             "invoiceIds",
             "dbIds",
             "severity",
             "message"
            ]
           }
          },
          "priceIncreases": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "vendor",
             "basis",
             "previousAmount",
             "latestAmount",
             "increasePct",
             "message"
            ]
           }
          }
         }
        }
       }
      }
     },
     "500": {
      "description": "{ detail:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/ask": {
   "post": {
    "operationId": "post_parser_finance_ask",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Natural-language query over your finance data.",
    "description": "Request, as the catalogue words it: The FinanceQuestion object: { question:string } required; blank after trimming is refused.\n\nReply, as the catalogue words it: 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\n\nGuard: answered by local LLM only, no data leaves box\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "requestBody": {
     "description": "The FinanceQuestion object: { question:string } required; blank after trimming is refused.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/FinanceQuestion"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ answer:string, aiUsed:boolean, grounded?:true (the answer was worked out from your figures without the model), aiDisabled?:true (AI features are switched off) }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "answer": {
           "type": "string"
          },
          "aiUsed": {
           "type": "boolean"
          },
          "grounded": {
           "type": "boolean",
           "description": "the answer was worked out from your figures without the model"
          },
          "aiDisabled": {
           "type": "boolean",
           "description": "AI features are switched off"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "missing question"
     },
     "500": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "FinanceQuestion"
    ],
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/variance": {
   "get": {
    "operationId": "get_parser_finance_variance",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Spend variance figures.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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 }\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "{ currentMonth:{ label, amount }, vsLastMonth:{ label, amount, delta, deltaPct }, vsLastYear:{ label, amount, delta, deltaPct }, movers:[] } — movers holds at most 8 categories whose spend changed",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "currentMonth": {
           "type": "object",
           "x-keys-not-described": [
            "label",
            "amount"
           ]
          },
          "vsLastMonth": {
           "type": "object",
           "x-keys-not-described": [
            "label",
            "amount",
            "delta",
            "deltaPct"
           ]
          },
          "vsLastYear": {
           "type": "object",
           "x-keys-not-described": [
            "label",
            "amount",
            "delta",
            "deltaPct"
           ]
          }
         },
         "x-keys-not-described": [
          "movers"
         ]
        }
       }
      }
     },
     "500": {
      "description": "{ detail:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/commitments": {
   "get": {
    "operationId": "get_parser_finance_commitments",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Open commitments.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { openPoCount, openPoValue, invoicedAgainst, uninvoicedCommitment } — over purchase orders that are neither closed nor cancelled, in base currency | 500 { detail:string }\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "{ openPoCount, openPoValue, invoicedAgainst, uninvoicedCommitment } — over purchase orders that are neither closed nor cancelled, in base currency",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "500": {
      "description": "{ detail:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/category-trend": {
   "get": {
    "operationId": "get_parser_finance_category_trend",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Spend by category over time.",
    "description": "Request, as the catalogue words it: Query: months (default 12). Clamped to 3–24.\n\nReply, as the catalogue words it: 200 { months:[label], series:[{ category, points:[] }] } — the 6 largest categories, the rest summed into one Other series | 500 { detail:string }\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "{ months:[label], series:[{ category, points:[] }] } — the 6 largest categories, the rest summed into one Other series",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "series": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "category",
             "points"
            ]
           }
          }
         },
         "x-keys-not-described": [
          "months"
         ]
        }
       }
      }
     },
     "500": {
      "description": "{ detail:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/saas-spend": {
   "get": {
    "operationId": "get_parser_finance_saas_spend",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Spend per SaaS application.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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 }\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "renewals": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "window",
             "count",
             "amount"
            ]
           }
          },
          "reclaimableByDepartment": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "department",
             "reclaimableMonthly"
            ]
           }
          }
         },
         "x-keys-not-described": [
          "apps",
          "totalSpend",
          "appCount",
          "vendorsWithoutOwner",
          "unusedCount",
          "overBudgetCount",
          "reclaimableMonthly",
          "withUsageCount"
         ]
        }
       }
      }
     },
     "500": {
      "description": "{ detail:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/saas/{vendor_id}/budget": {
   "put": {
    "operationId": "put_parser_finance_saas_vendor_id_budget",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Set or clear the annual budget of one application.",
    "description": "Request, as the catalogue words it: The AppBudgetWrite object: { amount?:number }. Zero, a negative number, a missing key, or a blank or non-numeric value clears the budget.\n\nReply, as the catalogue words it: 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)\n\nGuard: 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\n\nAuth: Session + manage_finance (licensed)",
    "parameters": [
     {
      "name": "vendor_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "The AppBudgetWrite object: { amount?:number }. Zero, a negative number, a missing key, or a blank or non-numeric value clears the budget.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AppBudgetWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ vendor, period, budget } — period is the current year",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "'App/vendor not found'"
     },
     "409": {
      "description": "when that budget is submitted, approved or locked (it then changes only through the budget routes; the refusal is audited)"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "AppBudgetWrite"
    ],
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/connectors": {
   "get": {
    "operationId": "get_parser_finance_connectors",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Finance connectors and their status.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { connectors:[{ key, name, category, status: connected | identity | available, billing, note }], connectedCount, identityCount, availableCount }. Not paged: the whole connector list.\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "{ connectors:[{ key, name, category, status: connected | identity | available, billing, note }], connectedCount, identityCount, availableCount }. Not paged: the whole connector list.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "connectors": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "key",
             "name",
             "category",
             "status",
             "billing",
             "note"
            ]
           }
          }
         },
         "x-keys-not-described": [
          "connectedCount",
          "identityCount",
          "availableCount"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/vendors": {
   "get": {
    "operationId": "get_parser_finance_vendors",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Vendor list.",
    "description": "Request, as the catalogue words it: No query parameters; not paged.\n\nReply, as the catalogue words it: 200 { vendors:[ Vendor ] } — every vendor, ordered by canonicalName | 500 { detail:string }\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "{ vendors:[ Vendor ] } — every vendor, ordered by canonicalName",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "500": {
      "description": "{ detail:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "RegistersVendor",
     "FinanceVendor"
    ],
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/vendors/{vendor_id}": {
   "patch": {
    "operationId": "patch_parser_finance_vendors_vendor_id",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Update vendor details.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 200 { success:true, id:integer } | 404 vendor not found | 409 another vendor already has that name | 500\n\nGuard: logs vendor_updated audit entry. Repeat: the same values are stored again and the answer is 200\n\nAuth: Session + manage_finance (licensed)",
    "parameters": [
     {
      "name": "vendor_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/VendorUpdate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success:true, id:integer }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          },
          "id": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "vendor not found"
     },
     "409": {
      "description": "another vendor already has that name"
     },
     "500": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "VendorUpdate"
    ],
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/vendors/bulk-update": {
   "post": {
    "operationId": "post_parser_finance_vendors_bulk_update",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Set the category and/or owner on many vendors.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: the same values are stored again; every vendor is reported ok and another vendor_updated audit entry is written for each\n\nAuth: Session + manage_finance (licensed)",
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/VendorBulkUpdate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "a BulkOutcome { ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { id, ok:boolean, error?:string }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/BulkOutcome"
        }
       }
      }
     },
     "400": {
      "description": "empty list, more than 500 ids, or a patch that sets neither field"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "BulkOutcomeItem",
     "BulkOutcome",
     "VendorBulkPatch",
     "VendorBulkUpdate"
    ],
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/vendors/merge": {
   "post": {
    "operationId": "post_parser_finance_vendors_merge",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Merge duplicate vendors.",
    "description": "Request, as the catalogue words it: The VendorMerge object: { keepId:integer, mergeId:integer }, both required (vendor ids).\n\nReply, as the catalogue words it: 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\n\nGuard: repoints invoices, absorbs aliases, deletes loser; logs vendor_merged. Repeat: when the merged vendor was deleted, a second call answers 400 'Invalid vendor ids'\n\nAuth: Session + manage_finance (licensed)",
    "requestBody": {
     "description": "The VendorMerge object: { keepId:integer, mergeId:integer }, both required (vendor ids).",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/VendorMerge"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "a VendorMergeResult { success:true, keptVendorId:integer, repointed:integer (invoices moved), skippedLocked:array (invoices left in place because their accounting period is locked), loserRetained:boolean }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/VendorMergeResult"
        }
       }
      }
     },
     "400": {
      "description": "missing ids or invalid/equal ids"
     },
     "500": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "VendorMergeResult",
     "VendorMerge"
    ],
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/vendor-custom-fields": {
   "get": {
    "operationId": "get_parser_finance_vendor_custom_fields",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Read the vendor custom-field schema.",
    "description": "Reply, as the catalogue words it: 200 { fields } — every field definition; not paged\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "{ fields } — every field definition; not paged",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-vendors-settings"
   },
   "post": {
    "operationId": "post_parser_finance_vendor_custom_fields",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Replace the vendor custom-field schema.",
    "description": "Request, as the catalogue words it: { fields:[ CustomFieldDefinition ] }: each { key:string, label?:string, type?: text | select | number | date, options?:string[] }. At most 20 fields. The call replaces the whole schema.\n\nReply, as the catalogue words it: 200 { fields } (as stored) | 400 when fields is not a list or has more than 20 entries\n\nGuard: Repeat: safe to repeat: the call replaces the whole schema, so the same list leaves it unchanged\n\nAuth: Session + manage_finance (licensed)",
    "requestBody": {
     "description": "{ fields:[ CustomFieldDefinition ] }: each { key:string, label?:string, type?: text | select | number | date, options?:string[] }. At most 20 fields. The call replaces the whole schema.",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "fields": {
          "type": "array",
          "items": {
           "$ref": "#/components/schemas/CustomFieldDefinition"
          }
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ fields } (as stored)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "when fields is not a list or has more than 20 entries"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CustomFieldDefinition"
    ],
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/allocation-custom-fields": {
   "get": {
    "operationId": "get_parser_finance_allocation_custom_fields",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Read the allocation custom-field schema.",
    "description": "Reply, as the catalogue words it: 200 { fields } — every field definition; not paged\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "{ fields } — every field definition; not paged",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-vendors-settings"
   },
   "post": {
    "operationId": "post_parser_finance_allocation_custom_fields",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Replace the allocation custom-field schema.",
    "description": "Request, as the catalogue words it: { fields:[ CustomFieldDefinition ] }: each { key:string, label?:string, type?: text | select | number | date, options?:string[] }. At most 20 fields. The call replaces the whole schema.\n\nReply, as the catalogue words it: 200 { fields } (as stored) | 400 when fields is not a list or has more than 20 entries\n\nGuard: Repeat: safe to repeat: the call replaces the whole schema, so the same list leaves it unchanged\n\nAuth: Session + manage_finance (licensed)",
    "requestBody": {
     "description": "{ fields:[ CustomFieldDefinition ] }: each { key:string, label?:string, type?: text | select | number | date, options?:string[] }. At most 20 fields. The call replaces the whole schema.",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "fields": {
          "type": "array",
          "items": {
           "$ref": "#/components/schemas/CustomFieldDefinition"
          }
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ fields } (as stored)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "when fields is not a list or has more than 20 entries"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CustomFieldDefinition"
    ],
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/allocation-values": {
   "get": {
    "operationId": "get_parser_finance_allocation_values",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Values already in use for the allocation fields, for pickers.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { costCenters:string[], projects:string[], departments:string[] } — each sorted; departments also includes those in the synced organisation directory. Not paged.\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "{ costCenters:string[], projects:string[], departments:string[] } — each sorted; departments also includes those in the synced organisation directory. Not paged.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "costCenters": {
           "type": "array",
           "items": {
            "type": "string"
           }
          },
          "projects": {
           "type": "array",
           "items": {
            "type": "string"
           }
          },
          "departments": {
           "type": "array",
           "items": {
            "type": "string"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/categories": {
   "get": {
    "operationId": "get_parser_finance_categories",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Spend categories in use.",
    "description": "Reply, as the catalogue words it: 200 { categories:string[] } (sorted); not paged\n\nAuth: Session + view_finance | manage_finance | submit_invoice (licensed)",
    "responses": {
     "200": {
      "description": "{ categories:string[] } (sorted); not paged",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "categories": {
           "type": "array",
           "items": {
            "type": "string"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | submit_invoice (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "submit_invoice",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/currency": {
   "get": {
    "operationId": "get_parser_finance_currency",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Read currency configuration.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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.\n\nAuth: Session + view_finance | manage_finance | manage_finance_policy (licensed)",
    "responses": {
     "200": {
      "description": "a CurrencyTable { baseCurrency:string, missingRates:array, rates:[ CurrencyRate ] } with each rate { code:string, rateToBase:number, updatedAt:string (date-time) } — rates ordered by code",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/CurrencyTable"
        }
       }
      }
     },
     "500": {
      "description": "{ detail:string } Every stored rate comes back; not paged.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | manage_finance_policy (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "manage_finance_policy",
     "view_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CurrencyRate",
     "CurrencyTable"
    ],
    "x-catalogue-group": "finance-vendors-settings"
   },
   "post": {
    "operationId": "post_parser_finance_currency",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Update currency configuration.",
    "description": "Request, as the catalogue words it: The CurrencyPolicyUpdate object: { baseCurrency?:string, code?:string, rateToBase?:number }. Sets the base currency, or one exchange rate (code and rateToBase together), or both.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_finance_policy (licensed)",
    "requestBody": {
     "description": "The CurrencyPolicyUpdate object: { baseCurrency?:string, code?:string, rateToBase?:number }. Sets the base currency, or one exchange rate (code and rateToBase together), or both.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/CurrencyPolicyUpdate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the CurrencyTable as it now stands, with base totals worked out again",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/CurrencyTable"
        }
       }
      }
     },
     "400": {
      "description": "invalid FX rate (non-numeric, <=0, or outside 1e-6..1e6)"
     },
     "500": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance_policy (licensed)",
    "x-permissions-named": [
     "manage_finance_policy"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CurrencyTable",
     "CurrencyPolicyUpdate"
    ],
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/currency/{code}": {
   "delete": {
    "operationId": "delete_parser_finance_currency_code",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Delete an exchange rate.",
    "description": "Request, as the catalogue words it: No body. code is the three-letter currency in the path; it is upper-cased.\n\nReply, as the catalogue words it: 200 the CurrencyTable without that rate | 404 No exchange rate for <code> | 409 when the rate is still in use | 400 with the reason\n\nGuard: manage_finance alone is refused 403 and the attempt is audited. Repeat: a second call answers 404 'No exchange rate for <code>'\n\nAuth: Session + manage_finance_policy (licensed)",
    "parameters": [
     {
      "name": "code",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Typed from the handler's signature: declared str in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "the CurrencyTable without that rate"
     },
     "400": {
      "description": "with the reason"
     },
     "404": {
      "description": "No exchange rate for <code>"
     },
     "409": {
      "description": "when the rate is still in use"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance_policy (licensed)",
    "x-permissions-named": [
     "manage_finance_policy"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/settings": {
   "get": {
    "operationId": "get_parser_finance_settings",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Read the editable finance settings with their defaults.",
    "description": "Reply, as the catalogue words it: 200 { settings:[{ key, label, kind, value, default, critical }] } — every setting; not paged\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "{ settings:[{ key, label, kind, value, default, critical }] } — every setting; not paged",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "settings": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "key",
             "label",
             "kind",
             "value",
             "default",
             "critical"
            ]
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-vendors-settings"
   },
   "put": {
    "operationId": "put_parser_finance_settings",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Change one finance setting.",
    "description": "Request, as the catalogue words it: The FinanceSettingWrite object: { key:string, value }. One setting per call.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_finance, or manage_security for a control setting (licensed)",
    "requestBody": {
     "description": "The FinanceSettingWrite object: { key:string, value }. One setting per call.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/FinanceSettingWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ key, value } (as stored)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "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"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance, or manage_security for a control setting (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "manage_security"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "FinanceSettingWrite"
    ],
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/digest": {
   "get": {
    "operationId": "get_parser_finance_digest",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "The finance digest summary (the scheduled digest email is built from the same summary).",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { baseCurrency, totalSpend, invoiceCount, committedCount, pendingApproval, awaitingFinance:{ draft, draftAmount, held, heldAmount }, topVendors:[{ name, amount }], exceptions, momVariance, fxWarnings, generatedAt } | 500 { detail:string }\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "{ baseCurrency, totalSpend, invoiceCount, committedCount, pendingApproval, awaitingFinance:{ draft, draftAmount, held, heldAmount }, topVendors:[{ name, amount }], exceptions, momVariance, fxWarnings, generatedAt }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "awaitingFinance": {
           "type": "object",
           "x-keys-not-described": [
            "draft",
            "draftAmount",
            "held",
            "heldAmount"
           ]
          },
          "topVendors": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "name",
             "amount"
            ]
           }
          }
         },
         "x-keys-not-described": [
          "baseCurrency",
          "totalSpend",
          "invoiceCount",
          "committedCount",
          "pendingApproval",
          "exceptions",
          "momVariance",
          "fxWarnings",
          "generatedAt"
         ]
        }
       }
      }
     },
     "500": {
      "description": "{ detail:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/digest/send": {
   "post": {
    "operationId": "post_parser_finance_digest_send",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Send the finance digest now.",
    "description": "Request, as the catalogue words it: The DigestSend object: { recipients?:string[] }, optional (email addresses); without it the configured recipients are used. Addresses are trimmed, lower-cased and de-duplicated.\n\nReply, as the catalogue words it: 200 { success:true, message:string } | 400 if send fails (message from send_digest)\n\nGuard: 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\n\nAuth: Session + manage_finance (licensed)",
    "requestBody": {
     "description": "The DigestSend object: { recipients?:string[] }, optional (email addresses); without it the configured recipients are used. Addresses are trimmed, lower-cased and de-duplicated.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DigestSend"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success:true, message:string }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          },
          "message": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "if send fails (message from send_digest)"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "DigestSend"
    ],
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/audit": {
   "get": {
    "operationId": "get_parser_finance_audit",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Finance audit trail.",
    "description": "Request, as the catalogue words it: Query: doc_id?, limit (default 200, clamped to 1–500).\n\nReply, as the catalogue words it: 200 { entries } — newest first: { id, docId, action, actorId, actorName, detail, at }; limit is the only paging control, there is no offset\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "parameters": [
     {
      "name": "doc_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer"
      }
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 200,
       "minimum": 1,
       "maximum": 500
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     }
    ],
    "responses": {
     "200": {
      "description": "{ entries } — newest first: { id, docId, action, actorId, actorName, detail, at }; limit is the only paging control, there is no offset",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-paging-parameters": [
     "limit"
    ],
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/audit.csv": {
   "get": {
    "operationId": "get_parser_finance_audit_csv",
    "tags": [
     "Finance: Insights, Vendors & Settings"
    ],
    "summary": "Controls-evidence export of the finance audit trail (auditor-ready CSV; who submitted/approved/paid/deleted what).",
    "description": "Request, as the catalogue words it: Query: doc_id? (scope to one document), action? (filter one action). No JSON body.\n\nReply, as the catalogue words it: 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\n\nGuard: logs an 'exported' finance-audit entry recording the export itself\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "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": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-vendors-settings"
   }
  },
  "/parser/finance/purchase-orders": {
   "get": {
    "operationId": "get_parser_finance_purchase_orders",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "List purchase orders.",
    "description": "Request, as the catalogue words it: Query: status? No paging parameters.\n\nReply, as the catalogue words it: 200 { items:[ PurchaseOrder ], baseCurrency:string } — newest first, at most 500\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "{ items:[ PurchaseOrder ], baseCurrency:string } — newest first, at most 500",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "items": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/PurchaseOrder"
           }
          },
          "baseCurrency": {
           "type": "string"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "PurchaseOrder"
    ],
    "x-catalogue-group": "finance-procurement"
   },
   "post": {
    "operationId": "post_parser_finance_purchase_orders",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Create a purchase order.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: the PO number is unique, so a second call with the same po_number answers 409 and creates nothing\n\nAuth: Session + manage_finance (licensed)",
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/PurchaseOrderCreate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success:true, id:integer, po_number:string }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          },
          "id": {
           "type": "integer"
          },
          "po_number": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "po_number is required"
     },
     "409": {
      "description": "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"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "PurchaseOrderLineInput",
     "PurchaseOrderCreate"
    ],
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/purchase-orders/{po_id}": {
   "get": {
    "operationId": "get_parser_finance_purchase_orders_po_id",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "One purchase order with its lines and receipts.",
    "description": "Reply, as the catalogue words it: 200 the PurchaseOrderDetail object | 404 'Purchase order not found'\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "parameters": [
     {
      "name": "po_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "the PurchaseOrderDetail object",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/PurchaseOrderDetail"
        }
       }
      }
     },
     "404": {
      "description": "'Purchase order not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "PurchaseOrderDetail"
    ],
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/purchase-orders/{po_id}/close": {
   "post": {
    "operationId": "post_parser_finance_purchase_orders_po_id_close",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Close, cancel or reopen a purchase order.",
    "description": "Request, as the catalogue words it: The PurchaseOrderStatusWrite object: { status?:string }: closed (default), cancelled or open.\n\nReply, as the catalogue words it: 200 { success:true, id:integer, status:string } | 404 not found | 400 an unknown status\n\nGuard: Repeat: the same status is stored again, the answer is 200 and another po_status_changed audit entry is written\n\nAuth: Session + manage_finance (licensed)",
    "parameters": [
     {
      "name": "po_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "The PurchaseOrderStatusWrite object: { status?:string }: closed (default), cancelled or open.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/PurchaseOrderStatusWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success:true, id:integer, status:string }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          },
          "id": {
           "type": "integer"
          },
          "status": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "an unknown status"
     },
     "404": {
      "description": "not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "PurchaseOrderStatusWrite"
    ],
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/purchase-orders/bulk-close": {
   "post": {
    "operationId": "post_parser_finance_purchase_orders_bulk_close",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Close, cancel or reopen many purchase orders.",
    "description": "Request, as the catalogue words it: The PurchaseOrderBulkStatus object: { ids:integer[] (1 to 500), status?:string }: closed (default), cancelled or open.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: as the single route: every order is reported ok again\n\nAuth: Session + manage_finance (licensed)",
    "requestBody": {
     "description": "The PurchaseOrderBulkStatus object: { ids:integer[] (1 to 500), status?:string }: closed (default), cancelled or open.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/PurchaseOrderBulkStatus"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "a BulkOutcome { ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { id, ok:boolean, error?:string }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/BulkOutcome"
        }
       }
      }
     },
     "400": {
      "description": "empty list, more than 500 ids, or an unknown status"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "BulkOutcomeItem",
     "BulkOutcome",
     "PurchaseOrderBulkStatus"
    ],
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/goods-receipts": {
   "post": {
    "operationId": "post_parser_finance_goods_receipts",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Record a goods receipt against a purchase order.",
    "description": "Request, as the catalogue words it: The GoodsReceiptCreate object: { po_id:integer } required; grn_number?:string, lines?:[ GoodsReceiptLineInput ] (each { po_line_id?:integer, quantity_received?:number }).\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: not deduplicated: every call records another receipt against the purchase order\n\nAuth: Session + manage_finance (licensed)",
    "requestBody": {
     "description": "The GoodsReceiptCreate object: { po_id:integer } required; grn_number?:string, lines?:[ GoodsReceiptLineInput ] (each { po_line_id?:integer, quantity_received?:number }).",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/GoodsReceiptCreate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success:true, id:integer }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          },
          "id": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "'Purchase order not found'. Not deduplicated: every call records another receipt against the purchase order, so read the purchase order back before re-sending"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "GoodsReceiptLineInput",
     "GoodsReceiptCreate"
    ],
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/match-exceptions": {
   "get": {
    "operationId": "get_parser_finance_match_exceptions",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Invoice-to-order match exceptions.",
    "description": "Request, as the catalogue words it: Query: status (default open). An empty status returns every status. No paging parameters.\n\nReply, as the catalogue words it: 200 { exceptions:[ MatchException ] } — newest first, at most 200\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "{ exceptions:[ MatchException ] } — newest first, at most 200",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "exceptions": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/MatchException"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "MatchException"
    ],
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/match-exceptions/{exc_id}/waive": {
   "post": {
    "operationId": "post_parser_finance_match_exceptions_exc_id_waive",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Waive one match exception.",
    "description": "Request, as the catalogue words it: The MatchExceptionWaive object: { reason:string } required.\n\nReply, as the catalogue words it: 200 { success:true } | 404 not found | 400 no reason | 403\n\nGuard: 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\n\nAuth: Session + manage_finance (licensed)",
    "parameters": [
     {
      "name": "exc_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "The MatchExceptionWaive object: { reason:string } required.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/MatchExceptionWaive"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "no reason"
     },
     "403": {
      "description": "No further text in the catalogue."
     },
     "404": {
      "description": "not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "MatchExceptionWaive"
    ],
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/match-exceptions/bulk-waive": {
   "post": {
    "operationId": "post_parser_finance_match_exceptions_bulk_waive",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Waive many match exceptions with one reason.",
    "description": "Request, as the catalogue words it: The MatchExceptionBulkWaive object: { ids:integer[] (1 to 500), reason:string }, both required.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\"\n\nAuth: Session + manage_finance (licensed)",
    "requestBody": {
     "description": "The MatchExceptionBulkWaive object: { ids:integer[] (1 to 500), reason:string }, both required.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/MatchExceptionBulkWaive"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "a BulkOutcome { ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { id, ok:boolean, error?:string }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/BulkOutcome"
        }
       }
      }
     },
     "400": {
      "description": "empty list, more than 500 ids, or no reason"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "BulkOutcomeItem",
     "BulkOutcome",
     "MatchExceptionBulkWaive"
    ],
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/approval-rules": {
   "get": {
    "operationId": "get_parser_finance_approval_rules",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "List the amount-tiered approval rules.",
    "description": "Reply, as the catalogue words it: 200 { rules:[ ApprovalRule ] }, lowest minAmount first; every rule, not paged\n\nAuth: Session + view_finance | manage_finance | manage_finance_policy (licensed)",
    "responses": {
     "200": {
      "description": "{ rules:[ ApprovalRule ] }, lowest minAmount first; every rule, not paged",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "rules": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/ApprovalRule"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | manage_finance_policy (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "manage_finance_policy",
     "view_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "ApprovalRule"
    ],
    "x-catalogue-group": "finance-procurement"
   },
   "post": {
    "operationId": "post_parser_finance_approval_rules",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Create an approval rule.",
    "description": "Request, as the catalogue words it: 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) }.\n\nReply, as the catalogue words it: 200 { success:true, id:integer } | 400 with the validation message\n\nGuard: 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\n\nAuth: Session + manage_finance_policy (licensed)",
    "requestBody": {
     "description": "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) }.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/ApprovalRuleWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success:true, id:integer }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          },
          "id": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "with the validation message"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance_policy (licensed)",
    "x-permissions-named": [
     "manage_finance_policy"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "ApprovalRuleWrite"
    ],
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/approval-rules/preview": {
   "get": {
    "operationId": "get_parser_finance_approval_rules_preview",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Show which approval tier an amount would fall in.",
    "description": "Request, as the catalogue words it: Query: amount (default 0).\n\nReply, as the catalogue words it: 200 { amount:number, requiredApprovals:integer, rule:ApprovalRule|null } | 400 an amount that cannot be read\n\nAuth: Session + view_finance | manage_finance | manage_finance_policy (licensed)",
    "responses": {
     "200": {
      "description": "{ amount:number, requiredApprovals:integer, rule:ApprovalRule|null }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "amount": {
           "type": "number"
          },
          "requiredApprovals": {
           "type": "integer"
          },
          "rule": {
           "anyOf": [
            {
             "$ref": "#/components/schemas/ApprovalRule"
            },
            {
             "type": "null"
            }
           ]
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "an amount that cannot be read"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | manage_finance_policy (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "manage_finance_policy",
     "view_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "ApprovalRule"
    ],
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/approval-rules/{rule_id}": {
   "patch": {
    "operationId": "patch_parser_finance_approval_rules_rule_id",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Change an approval rule.",
    "description": "Request, as the catalogue words it: The ApprovalRuleWrite object; only the keys sent are changed, and the result is validated as a whole (maxAmount must stay above minAmount).\n\nReply, as the catalogue words it: 200 { success:true } | 400 with the validation message | 404 'Rule not found'\n\nGuard: 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\n\nAuth: Session + manage_finance_policy (licensed)",
    "parameters": [
     {
      "name": "rule_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "The ApprovalRuleWrite object; only the keys sent are changed, and the result is validated as a whole (maxAmount must stay above minAmount).",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/ApprovalRuleWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "with the validation message"
     },
     "404": {
      "description": "'Rule not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance_policy (licensed)",
    "x-permissions-named": [
     "manage_finance_policy"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "ApprovalRuleWrite"
    ],
    "x-catalogue-group": "finance-procurement"
   },
   "delete": {
    "operationId": "delete_parser_finance_approval_rules_rule_id",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Delete an approval rule.",
    "description": "Request, as the catalogue words it: No body. rule_id is an integer in the path.\n\nReply, as the catalogue words it: 200 { success:true }\n\nGuard: 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\n\nAuth: Session + manage_finance_policy (licensed)",
    "parameters": [
     {
      "name": "rule_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{ success:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance_policy (licensed)",
    "x-permissions-named": [
     "manage_finance_policy"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/approvals-queue": {
   "get": {
    "operationId": "get_parser_finance_approvals_queue",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Submitted invoices awaiting approval, oldest first, with approval progress.",
    "description": "Reply, as the catalogue words it: 200 { queue:[ ApprovalQueueItem ] } — at most 200 rows\n\nAuth: Session + view_finance | manage_finance | approve_invoice (licensed)",
    "responses": {
     "200": {
      "description": "{ queue:[ ApprovalQueueItem ] } — at most 200 rows",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "queue": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/ApprovalQueueItem"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | approve_invoice (licensed)",
    "x-permissions-named": [
     "approve_invoice",
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "ApprovalQueueItem"
    ],
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/documents/{doc_id}/approvals": {
   "get": {
    "operationId": "get_parser_finance_documents_doc_id_approvals",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Approval history and progress of one invoice.",
    "description": "Reply, as the catalogue words it: 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'\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "parameters": [
     {
      "name": "doc_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "required": {
           "type": "integer"
          },
          "count": {
           "type": "integer",
           "description": "distinct approvers so far"
          },
          "actions": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "actorId": {
              "type": [
               "integer",
               "null"
              ]
             },
             "actorName": {
              "type": [
               "string",
               "null"
              ]
             },
             "decision": {
              "type": "string"
             },
             "comment": {
              "type": [
               "string",
               "null"
              ]
             },
             "at": {
              "type": "string",
              "format": "date-time"
             }
            }
           }
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "'Document not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/gl-accounts": {
   "get": {
    "operationId": "get_parser_finance_gl_accounts",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "List the chart of accounts (GL accounts).",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { accounts:[ GLAccount ] } (ordered by code); every account, not paged\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "{ accounts:[ GLAccount ] } (ordered by code); every account, not paged",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "accounts": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/GLAccount"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "GLAccount"
    ],
    "x-catalogue-group": "finance-procurement"
   },
   "post": {
    "operationId": "post_parser_finance_gl_accounts",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Create a GL account.",
    "description": "Request, as the catalogue words it: The GLAccountCreate object: { code:string } required; name?:string (defaults to code), type?:string.\n\nReply, as the catalogue words it: 200 { success:true, id:integer } | 400 code is required | 409 GL account <code> already exists\n\nGuard: code unique; logs gl_account_created. Repeat: the code is unique, so a second call with the same code answers 409 and creates nothing\n\nAuth: Session + manage_finance (licensed)",
    "requestBody": {
     "description": "The GLAccountCreate object: { code:string } required; name?:string (defaults to code), type?:string.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/GLAccountCreate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success:true, id:integer }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          },
          "id": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "code is required"
     },
     "409": {
      "description": "GL account <code> already exists"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "GLAccountCreate"
    ],
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/close/{period}": {
   "get": {
    "operationId": "get_parser_finance_close_period",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Month-end close readiness for a period (open blockers: documents in review/awaiting approval, unresolved match exceptions).",
    "description": "Request, as the catalogue words it: period path param (e.g. '2026-08'). No body.\n\nReply, as the catalogue words it: 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\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "parameters": [
     {
      "name": "period",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Typed from the handler's signature: declared str in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "period": {
           "type": "string"
          },
          "status": {
           "type": "string",
           "description": "open until the period is locked"
          },
          "needsReview": {
           "type": "integer"
          },
          "awaitingApproval": {
           "type": "integer"
          },
          "openExceptions": {
           "type": "integer"
          },
          "committed": {
           "type": "integer"
          },
          "total": {
           "type": "integer"
          },
          "uncoded": {
           "type": "integer"
          },
          "readyPct": {
           "type": "integer"
          },
          "canLock": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-procurement"
   },
   "post": {
    "operationId": "post_parser_finance_close_period",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + close_finance (licensed)",
    "parameters": [
     {
      "name": "period",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Typed from the handler's signature: declared str in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/PeriodClose"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "a PeriodCloseResult { period:string, status:'locked', journalsPosted:integer, batch:string, forced:boolean }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/PeriodCloseResult"
        }
       }
      }
     },
     "400": {
      "description": "'Forcing a close over open items requires a written reason.'"
     },
     "409": {
      "description": "'Period <period> is already locked.' — what a repeat gets | when the period still has open blockers and force is not set"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + close_finance (licensed)",
    "x-permissions-named": [
     "close_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "PeriodCloseResult",
     "PeriodClose"
    ],
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/close/{period}/journals.csv": {
   "get": {
    "operationId": "get_parser_finance_close_period_journals_csv",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Export the period's double-entry GL journals as CSV in a QuickBooks, Xero, or generic template.",
    "description": "Request, as the catalogue words it: period path param; query format=generic|quickbooks|xero (default generic; unknown falls back to generic). No JSON body.\n\nReply, as the catalogue words it: 200 text/csv attachment 'journals_<period>_<fmt>.csv'\n\nGuard: logs an 'exported' entry recording the period + format\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "parameters": [
     {
      "name": "period",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Typed from the handler's signature: declared str in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "text/csv attachment 'journals_<period>_<fmt>.csv'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/adjustments": {
   "post": {
    "operationId": "post_parser_finance_adjustments",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Post a manual adjustment journal.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_finance | close_finance (licensed)",
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AdjustmentCreate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ period, batch, amount, debitGl, creditGl } — booked to the current period",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "{ detail:string } for a missing reason, a missing or identical account, an account not in the chart, or an amount that is not positive",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "409": {
      "description": "{ detail:string } when the current period is locked",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | close_finance (licensed)",
    "x-permissions-named": [
     "close_finance",
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "AdjustmentCreate"
    ],
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/reconciliation": {
   "get": {
    "operationId": "get_parser_finance_reconciliation",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Reconciliation figures for one period.",
    "description": "Request, as the catalogue words it: Query: period (required).\n\nReply, as the catalogue words it: 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\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | close_finance (licensed)",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "period": {
           "type": "string"
          },
          "invoiceActual": {
           "type": "number"
          },
          "glPosted": {
           "type": "number"
          },
          "difference": {
           "type": "number"
          },
          "accounts": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "glCode": {
              "type": "string"
             },
             "invoiceActual": {
              "type": "number"
             },
             "glPosted": {
              "type": "number"
             },
             "difference": {
              "type": "number"
             }
            }
           }
          }
         },
         "x-keys-not-described": [
          "reconcilingItems"
         ]
        }
       }
      }
     },
     "400": {
      "description": "when it is empty"
     },
     "422": {
      "description": "when period is absent"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | close_finance (licensed)",
    "x-permissions-named": [
     "close_finance",
     "manage_all_finance_budgets",
     "manage_finance",
     "view_finance",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/gl-sync": {
   "get": {
    "operationId": "get_parser_finance_gl_sync",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Outbound GL-sync status: active connector, and synced vs pending journal counts.",
    "description": "Request, as the catalogue words it: Query: period? optional (scope counts to one period). No JSON body.\n\nReply, as the catalogue words it: 200 { connector, period, total, synced, pending, live, configured } — 'manual' is always configured; a live connector reports configured:false until its credentials are supplied\n\nAuth: Session + view_finance | manage_finance (licensed)",
    "responses": {
     "200": {
      "description": "{ connector, period, total, synced, pending, live, configured } — 'manual' is always configured; a live connector reports configured:false until its credentials are supplied",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance",
     "view_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/gl-sync/connector": {
   "put": {
    "operationId": "put_parser_finance_gl_sync_connector",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Choose the outbound GL connector.",
    "description": "Request, as the catalogue words it: The GLConnectorWrite object: { connector:string }: one of manual | quickbooks | netsuite | xero.\n\nReply, as the catalogue words it: 200 { connector } | 400 Unknown GL connector '<x>'\n\nGuard: 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\n\nAuth: Session + manage_finance (licensed)",
    "requestBody": {
     "description": "The GLConnectorWrite object: { connector:string }: one of manual | quickbooks | netsuite | xero.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/GLConnectorWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ connector }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "Unknown GL connector '<x>'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "GLConnectorWrite"
    ],
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/gl-sync/{period}": {
   "post": {
    "operationId": "post_parser_finance_gl_sync_period",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: No body. period is in the path.\n\nReply, as the catalogue words it: 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\n\nGuard: '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\n\nAuth: Session + manage_finance (licensed)",
    "parameters": [
     {
      "name": "period",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Typed from the handler's signature: declared str in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "a GLSyncRun { connector:string, pushed:integer, live:boolean, note:string }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/GLSyncRun"
        }
       }
      }
     },
     "400": {
      "description": "(fail-closed) if the selected live connector's OAuth credentials are not configured — never a silent no-op"
     },
     "500": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance (licensed)",
    "x-permissions-named": [
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "GLSyncRun"
    ],
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/chargeback": {
   "get": {
    "operationId": "get_parser_finance_chargeback",
    "tags": [
     "Finance: Purchasing, Approvals & Close"
    ],
    "summary": "Chargeback breakdown by cost centre.",
    "description": "Request, as the catalogue words it: Query: dimension (default cost_center), period? dimension is cost_center | department | project | category; any other value is treated as cost_center.\n\nReply, as the catalogue words it: 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 }\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "allocations": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "label",
             "amount",
             "invoiceCount",
             "sharePct"
            ]
           }
          }
         },
         "x-keys-not-described": [
          "dimension",
          "period"
         ]
        }
       }
      }
     },
     "500": {
      "description": "{ detail:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets (licensed)",
    "x-permissions-named": [
     "manage_all_finance_budgets",
     "manage_finance",
     "view_finance",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-procurement"
   }
  },
  "/parser/finance/budgets": {
   "get": {
    "operationId": "get_parser_finance_budgets",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "List budgets.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 200 { budgets:[ Budget ] } — every budget in the caller's scope that matches; not paged | 500 { detail:string }\n\nGuard: scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "responses": {
     "200": {
      "description": "{ budgets:[ Budget ] } — every budget in the caller's scope that matches; not paged",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "budgets": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/Budget"
           }
          }
         }
        }
       }
      }
     },
     "500": {
      "description": "{ detail:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Budget"
    ],
    "x-catalogue-group": "finance-budgets"
   },
   "post": {
    "operationId": "post_parser_finance_budgets",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Create a budget.",
    "description": "Request, as the catalogue words it: 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).\n\nReply, as the catalogue words it: 200 the Budget object | 400 missing period or an invalid value | 403 a scope outside the caller's authority (audited) | 500\n\nGuard: 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\n\nAuth: Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "requestBody": {
     "description": "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).",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/BudgetWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the Budget object",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Budget"
        }
       }
      }
     },
     "400": {
      "description": "missing period or an invalid value"
     },
     "403": {
      "description": "a scope outside the caller's authority (audited)"
     },
     "500": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Budget"
    ],
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/{budget_id}": {
   "patch": {
    "operationId": "patch_parser_finance_budgets_budget_id",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Update a budget.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: a scoped maker can edit only their own budget; editing an approved budget that was unlocked sends it back for approval; logs budget_updated\n\nAuth: Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/BudgetWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the Budget object",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Budget"
        }
       }
      }
     },
     "400": {
      "description": "No further text in the catalogue."
     },
     "403": {
      "description": "not your budget (audited)"
     },
     "404": {
      "description": "budget not found"
     },
     "409": {
      "description": "the budget is locked (approved) — reforecast it to get an editable version"
     },
     "500": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Budget",
     "BudgetWrite"
    ],
    "x-catalogue-group": "finance-budgets"
   },
   "delete": {
    "operationId": "delete_parser_finance_budgets_budget_id",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Delete a budget.",
    "description": "Request, as the catalogue words it: No body. budget_id path param (int).\n\nReply, as the catalogue words it: 200 {success:true} | 403 not your budget | 404 budget not found | 409 an approved budget cannot be deleted\n\nGuard: logs budget_deleted. Repeat: a second call answers 404 'Budget not found'\n\nAuth: Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{success:true}",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "403": {
      "description": "not your budget"
     },
     "404": {
      "description": "budget not found"
     },
     "409": {
      "description": "an approved budget cannot be deleted"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/summary": {
   "get": {
    "operationId": "get_parser_finance_budgets_summary",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Organisation-wide budget summary.",
    "description": "Request, as the catalogue words it: Query: period?\n\nReply, as the catalogue words it: 200 { departments:[], totals } — departments ordered by budget, largest first. Not paged. | 500 { detail:string }\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets (licensed)",
    "responses": {
     "200": {
      "description": "{ departments:[], totals } — departments ordered by budget, largest first. Not paged.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "500": {
      "description": "{ detail:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets (licensed)",
    "x-permissions-named": [
     "manage_all_finance_budgets",
     "manage_finance",
     "view_finance",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/export.csv": {
   "get": {
    "operationId": "get_parser_finance_budgets_export_csv",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Budget, actual and committed figures as CSV.",
    "description": "Reply, as the catalogue words it: 200 text/csv: every budget the caller may see, in one file; no query parameters are read\n\nGuard: scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "responses": {
     "200": {
      "description": "text/csv: every budget the caller may see, in one file; no query parameters are read"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/approvals-inbox": {
   "get": {
    "operationId": "get_parser_finance_budgets_approvals_inbox",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Budget approvals inbox for the caller.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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 }\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "budgets": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/Budget"
           }
          },
          "transfers": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/BudgetTransfer"
           }
          },
          "count": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "500": {
      "description": "{ detail:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Budget",
     "BudgetTransfer"
    ],
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/alerts": {
   "get": {
    "operationId": "get_parser_finance_budgets_alerts",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Budget alerts.",
    "description": "Request, as the catalogue words it: Query: includeAck?:boolean (default false).\n\nReply, as the catalogue words it: 200 { alerts:[ BudgetAlert ] } — newest first, at most 100; no paging parameters\n\nGuard: scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "responses": {
     "200": {
      "description": "{ alerts:[ BudgetAlert ] } — newest first, at most 100; no paging parameters",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "alerts": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/BudgetAlert"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "BudgetAlert"
    ],
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/alerts/sweep": {
   "post": {
    "operationId": "post_parser_finance_budgets_alerts_sweep",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Run the budget alert check now.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 an AlertSweepResult { evaluated:integer, fired:integer } | 500 { detail:string }\n\nGuard: 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\n\nAuth: Session + manage_finance | manage_all_finance_budgets | view_finance_consolidated (licensed)",
    "responses": {
     "200": {
      "description": "an AlertSweepResult { evaluated:integer, fired:integer }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AlertSweepResult"
        }
       }
      }
     },
     "500": {
      "description": "{ detail:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | manage_all_finance_budgets | view_finance_consolidated (licensed)",
    "x-permissions-named": [
     "manage_all_finance_budgets",
     "manage_finance",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "AlertSweepResult"
    ],
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/alerts/{alert_id}/ack": {
   "post": {
    "operationId": "post_parser_finance_budgets_alerts_alert_id_ack",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Acknowledge a budget alert.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { success:true } | 404 'Alert not found'\n\nGuard: Repeat: a second call answers 200 and records the newer acknowledgement\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "parameters": [
     {
      "name": "alert_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{ success:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "'Alert not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/audit": {
   "get": {
    "operationId": "get_parser_finance_budgets_audit",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Audit trail of budget actions.",
    "description": "Request, as the catalogue words it: Query: limit (default 500, clamped to 1–2000).\n\nReply, as the catalogue words it: 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\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | close_finance (licensed)",
    "parameters": [
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 500,
       "minimum": 1,
       "maximum": 2000
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "entries": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "id": {
              "type": "integer"
             },
             "docId": {
              "type": [
               "integer",
               "null"
              ]
             },
             "action": {
              "type": "string"
             },
             "actorId": {
              "type": [
               "integer",
               "null"
              ]
             },
             "actorName": {
              "type": [
               "string",
               "null"
              ]
             },
             "detail": {
              "type": [
               "string",
               "null"
              ]
             },
             "at": {
              "type": "string",
              "format": "date-time"
             }
            }
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | close_finance (licensed)",
    "x-permissions-named": [
     "close_finance",
     "manage_all_finance_budgets",
     "manage_finance",
     "view_finance",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-paging-parameters": [
     "limit"
    ],
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/audit.csv": {
   "get": {
    "operationId": "get_parser_finance_budgets_audit_csv",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Audit trail of budget actions as CSV.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | close_finance (licensed)",
    "responses": {
     "200": {
      "description": "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"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | close_finance (licensed)",
    "x-permissions-named": [
     "close_finance",
     "manage_all_finance_budgets",
     "manage_finance",
     "view_finance",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/{budget_id}/submit": {
   "post": {
    "operationId": "post_parser_finance_budgets_budget_id_submit",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Submit a budget for approval.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: a second call answers 409, because the budget is no longer Draft or Rejected\n\nAuth: Session + manage_finance | manage_all_finance_budgets | manage_finance_budget | submit_finance_budget (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "integer"
          },
          "status": {
           "type": "string"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget | submit_finance_budget (licensed)",
    "x-permissions-named": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "submit_finance_budget"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/{budget_id}/approve": {
   "post": {
    "operationId": "post_parser_finance_budgets_budget_id_approve",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Approve a submitted budget.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { id:integer, status:string, locked:boolean } | the lifecycle error status with its message — 409 unless the budget is Submitted (what a repeat gets)\n\nGuard: Repeat: a second call answers 409, because the budget is no longer Submitted\n\nAuth: Session + manage_finance | manage_all_finance_budgets | approve_finance_budget (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{ id:integer, status:string, locked:boolean } | the lifecycle error status with its message — 409 unless the budget is Submitted (what a repeat gets)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "integer"
          },
          "status": {
           "type": "string"
          },
          "locked": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | manage_all_finance_budgets | approve_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/{budget_id}/reject": {
   "post": {
    "operationId": "post_parser_finance_budgets_budget_id_reject",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Reject a submitted budget.",
    "description": "Request, as the catalogue words it: The BudgetReject object: { reason:string } required.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: a second call answers 409, because the budget is no longer Submitted\n\nAuth: Session + manage_finance | manage_all_finance_budgets | approve_finance_budget (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "The BudgetReject object: { reason:string } required.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/BudgetReject"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "integer"
          },
          "status": {
           "type": "string"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | manage_all_finance_budgets | approve_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "BudgetReject"
    ],
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/{budget_id}/reforecast": {
   "post": {
    "operationId": "post_parser_finance_budgets_budget_id_reforecast",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Create a new editable version of an approved budget.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: every call creates a further version and makes it the current one\n\nAuth: Session + manage_finance | manage_all_finance_budgets | manage_finance_budget | submit_finance_budget (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{ id:integer, versionNo:integer, status:string } — the new version",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "integer"
          },
          "versionNo": {
           "type": "integer"
          },
          "status": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "403": {
      "description": "for a budget that is not the caller's"
     },
     "409": {
      "description": "for a historical version"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget | submit_finance_budget (licensed)",
    "x-permissions-named": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "submit_finance_budget"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/{budget_id}/lock": {
   "post": {
    "operationId": "post_parser_finance_budgets_budget_id_lock",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Lock or unlock a budget.",
    "description": "Request, as the catalogue words it: The BudgetLock object: { locked?:boolean }, default true. A call with no body locks.\n\nReply, as the catalogue words it: 200 { id:integer, locked:boolean } | the lifecycle error status with its message\n\nGuard: Repeat: the same lock state is stored again, the answer is 200 and another budget_locked or budget_unlocked audit entry is written\n\nAuth: Session + manage_finance | manage_all_finance_budgets | approve_finance_budget | manage_finance_policy (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "The BudgetLock object: { locked?:boolean }, default true. A call with no body locks.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/BudgetLock"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ id:integer, locked:boolean } | the lifecycle error status with its message",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "integer"
          },
          "locked": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | manage_all_finance_budgets | approve_finance_budget | manage_finance_policy (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_policy"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "BudgetLock"
    ],
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/{budget_id}/freeze": {
   "post": {
    "operationId": "post_parser_finance_budgets_budget_id_freeze",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Freeze or release a budget. An invoice approval against a frozen budget is refused with 409, and a budget override does not lift it.",
    "description": "Request, as the catalogue words it: The BudgetFreeze object: { frozen?:boolean (default true), reason?:string }. A call with no body freezes.\n\nReply, as the catalogue words it: 200 { id:integer, frozen:boolean }\n\nGuard: 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\n\nAuth: Session + manage_finance | manage_all_finance_budgets | close_finance (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "The BudgetFreeze object: { frozen?:boolean (default true), reason?:string }. A call with no body freezes.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/BudgetFreeze"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ id:integer, frozen:boolean }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "integer"
          },
          "frozen": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | manage_all_finance_budgets | close_finance (licensed)",
    "x-permissions-named": [
     "close_finance",
     "manage_all_finance_budgets",
     "manage_finance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "BudgetFreeze"
    ],
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/{budget_id}/copy": {
   "post": {
    "operationId": "post_parser_finance_budgets_budget_id_copy",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Copy a budget forward into another period.",
    "description": "Request, as the catalogue words it: The BudgetCopy object: { period:string } required ('YYYY-MM' or 'YYYY').\n\nReply, as the catalogue words it: 200 the new Budget object, a Draft at version 1 with the lines of the source | 400 period is required\n\nGuard: a scoped maker owns the copy. Repeat: not deduplicated: every call creates another copy for the period\n\nAuth: Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "The BudgetCopy object: { period:string } required ('YYYY-MM' or 'YYYY').",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/BudgetCopy"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the new Budget object, a Draft at version 1 with the lines of the source"
     },
     "400": {
      "description": "period is required"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Budget",
     "BudgetCopy"
    ],
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/{budget_id}/actuals": {
   "get": {
    "operationId": "get_parser_finance_budgets_budget_id_actuals",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Actual spend behind a budget.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nGuard: scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{ invoices:[], commitments:[], cloud, invoiceCount, invoiceTruncated, invoiceSum, commitmentsTracked } — invoices are newest first and capped; invoiceTruncated is true when invoiceCount is larger than the list returned",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/{budget_id}/versions": {
   "get": {
    "operationId": "get_parser_finance_budgets_budget_id_versions",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Version history of a budget.",
    "description": "Reply, as the catalogue words it: 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\n\nGuard: scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "versions": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "id": {
              "type": "integer"
             },
             "versionNo": {
              "type": "integer"
             },
             "versionLabel": {
              "type": [
               "string",
               "null"
              ]
             },
             "amount": {
              "type": "number"
             },
             "status": {
              "type": "string"
             },
             "isCurrent": {
              "type": "boolean"
             },
             "locked": {
              "type": "boolean"
             },
             "approvedBy": {
              "type": [
               "integer",
               "null"
              ]
             },
             "approvedByEmail": {
              "type": [
               "string",
               "null"
              ]
             },
             "approvedAt": {
              "type": [
               "string",
               "null"
              ],
              "format": "date-time"
             }
            }
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/{budget_id}/forecast-accuracy": {
   "get": {
    "operationId": "get_parser_finance_budgets_budget_id_forecast_accuracy",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Forecast against actual, per version.",
    "description": "Reply, as the catalogue words it: 200 { history } — [{ period, forecast, actual, … }], oldest period first; not paged\n\nGuard: scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{ history } — [{ period, forecast, actual, … }], oldest period first; not paged",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/{budget_id}/approval-records": {
   "get": {
    "operationId": "get_parser_finance_budgets_budget_id_approval_records",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Approval records of a budget.",
    "description": "Reply, as the catalogue words it: 200 { records } | 404 'Budget not found' — newest decision first: { id, decision, approver, owner, authorityBasis, managerOfOwnerAtTime, versionNo, decidedAt }. Every record, not paged\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget | close_finance (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{ records }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "'Budget not found' — newest decision first: { id, decision, approver, owner, authorityBasis, managerOfOwnerAtTime, versionNo, decidedAt }. Every record, not paged",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget | close_finance (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "close_finance",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/{budget_id}/lines": {
   "get": {
    "operationId": "get_parser_finance_budgets_budget_id_lines",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Line items of a budget.",
    "description": "Reply, as the catalogue words it: 200 { lines:[ BudgetLine ], method:string }; every line, not paged\n\nGuard: scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{ lines:[ BudgetLine ], method:string }; every line, not paged",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "lines": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/BudgetLine"
           }
          },
          "method": {
           "type": "string"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "BudgetLine"
    ],
    "x-catalogue-group": "finance-budgets"
   },
   "post": {
    "operationId": "post_parser_finance_budgets_budget_id_lines",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Add a line to a budget.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 200 the BudgetLine | 404 budget not found | 403 not your budget | 409 the budget is locked\n\nGuard: editing the lines of an approved budget sends it back for approval. Repeat: not deduplicated: every call adds another line\n\nAuth: Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/BudgetLineWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the BudgetLine",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/BudgetLine"
        }
       }
      }
     },
     "403": {
      "description": "not your budget"
     },
     "404": {
      "description": "budget not found"
     },
     "409": {
      "description": "the budget is locked"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "BudgetLine",
     "BudgetLineWrite"
    ],
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/{budget_id}/lines/{line_id}": {
   "patch": {
    "operationId": "patch_parser_finance_budgets_budget_id_lines_line_id",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Change a budget line.",
    "description": "Request, as the catalogue words it: The BudgetLineWrite object; only keys present are applied and the line amount is recalculated.\n\nReply, as the catalogue words it: 200 the BudgetLine | 404 'Line not found' — also when the line belongs to another budget | 403 not your budget | 409 the budget is locked\n\nAuth: Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     },
     {
      "name": "line_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "The BudgetLineWrite object; only keys present are applied and the line amount is recalculated.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/BudgetLineWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the BudgetLine",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/BudgetLine"
        }
       }
      }
     },
     "403": {
      "description": "not your budget"
     },
     "404": {
      "description": "'Line not found' — also when the line belongs to another budget"
     },
     "409": {
      "description": "the budget is locked"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "BudgetLine",
     "BudgetLineWrite"
    ],
    "x-catalogue-group": "finance-budgets"
   },
   "delete": {
    "operationId": "delete_parser_finance_budgets_budget_id_lines_line_id",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Delete a budget line.",
    "description": "Request, as the catalogue words it: No body. budget_id and line_id are integers in the path.\n\nReply, as the catalogue words it: 200 { success:true } | 404 'Line not found' | 403 not your budget | 409 the budget is locked\n\nGuard: Repeat: a second call answers 404 'Line not found'\n\nAuth: Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     },
     {
      "name": "line_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{ success:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "403": {
      "description": "not your budget"
     },
     "404": {
      "description": "'Line not found'"
     },
     "409": {
      "description": "the budget is locked"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/budgets/{budget_id}/comments": {
   "get": {
    "operationId": "get_parser_finance_budgets_budget_id_comments",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Comments on a budget.",
    "description": "Reply, as the catalogue words it: 200 { comments:[ BudgetComment ] }; every comment, not paged\n\nGuard: scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{ comments:[ BudgetComment ] }; every comment, not paged",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "comments": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/BudgetComment"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "BudgetComment"
    ],
    "x-catalogue-group": "finance-budgets"
   },
   "post": {
    "operationId": "post_parser_finance_budgets_budget_id_comments",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Comment on a budget.",
    "description": "Request, as the catalogue words it: The BudgetCommentCreate object: { comment:string } required.\n\nReply, as the catalogue words it: 200 the BudgetComment | 400 comment is required\n\nGuard: 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\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "parameters": [
     {
      "name": "budget_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "The BudgetCommentCreate object: { comment:string } required.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/BudgetCommentCreate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the BudgetComment",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/BudgetComment"
        }
       }
      }
     },
     "400": {
      "description": "comment is required"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "BudgetComment",
     "BudgetCommentCreate"
    ],
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/transfers": {
   "get": {
    "operationId": "get_parser_finance_transfers",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Budget transfers the caller may see.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { transfers:[ BudgetTransfer ] } — newest first, at most 200\n\nAuth: Session + transfer_finance_budget | manage_all_finance_budgets (licensed)",
    "responses": {
     "200": {
      "description": "{ transfers:[ BudgetTransfer ] } — newest first, at most 200",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "transfers": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/BudgetTransfer"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + transfer_finance_budget | manage_all_finance_budgets (licensed)",
    "x-permissions-named": [
     "manage_all_finance_budgets",
     "transfer_finance_budget"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "BudgetTransfer"
    ],
    "x-catalogue-group": "finance-budgets"
   },
   "post": {
    "operationId": "post_parser_finance_transfers",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Request a transfer between two budgets.",
    "description": "Request, as the catalogue words it: The BudgetTransferCreate object: { fromBudgetId:integer, toBudgetId:integer, amount:number } required; currency?:string, reason?:string.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: not deduplicated: every call records another pending transfer\n\nAuth: Session + transfer_finance_budget | manage_all_finance_budgets (licensed)",
    "requestBody": {
     "description": "The BudgetTransferCreate object: { fromBudgetId:integer, toBudgetId:integer, amount:number } required; currency?:string, reason?:string.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/BudgetTransferCreate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/BudgetTransfer"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + transfer_finance_budget | manage_all_finance_budgets (licensed)",
    "x-permissions-named": [
     "manage_all_finance_budgets",
     "transfer_finance_budget"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "BudgetTransfer",
     "BudgetTransferCreate"
    ],
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/transfers/{transfer_id}/approve": {
   "post": {
    "operationId": "post_parser_finance_transfers_transfer_id_approve",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Approve a budget transfer.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + transfer_finance_budget | manage_all_finance_budgets (licensed)",
    "parameters": [
     {
      "name": "transfer_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/BudgetTransfer"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + transfer_finance_budget | manage_all_finance_budgets (licensed)",
    "x-permissions-named": [
     "manage_all_finance_budgets",
     "transfer_finance_budget"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "BudgetTransfer"
    ],
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/transfers/{transfer_id}/reject": {
   "post": {
    "operationId": "post_parser_finance_transfers_transfer_id_reject",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Reject a budget transfer.",
    "description": "Request, as the catalogue words it: The TransferReject object: { reason?:string }.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: a second call answers 409, because the transfer is no longer Pending\n\nAuth: Session + transfer_finance_budget | manage_all_finance_budgets (licensed)",
    "parameters": [
     {
      "name": "transfer_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "The TransferReject object: { reason?:string }.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/TransferReject"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/BudgetTransfer"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + transfer_finance_budget | manage_all_finance_budgets (licensed)",
    "x-permissions-named": [
     "manage_all_finance_budgets",
     "transfer_finance_budget"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "BudgetTransfer",
     "TransferReject"
    ],
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/approvals/bulk": {
   "post": {
    "operationId": "post_parser_finance_approvals_bulk",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Approve or reject many budgets and transfers in one call.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_finance | manage_all_finance_budgets | approve_finance_budget | transfer_finance_budget (licensed)",
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/BudgetApprovalsBulk"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "a BulkOutcome { action:string, ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { kind: budget | transfer, id, ok:boolean, error?:string }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/BulkOutcome"
        }
       }
      }
     },
     "400": {
      "description": "unknown action, ids that are not lists, nothing to act on, or more than 500 items"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | manage_all_finance_budgets | approve_finance_budget | transfer_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "transfer_finance_budget"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "BulkOutcomeItem",
     "BulkOutcome",
     "BudgetApprovalsBulk"
    ],
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/spend-requests": {
   "get": {
    "operationId": "get_parser_finance_spend_requests",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Spend requests the caller may see.",
    "description": "Request, as the catalogue words it: Query: includeClosed?:boolean (default false).\n\nReply, as the catalogue words it: 200 { requests:[ SpendRequest ] } — newest first, at most 200; no paging parameters\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "responses": {
     "200": {
      "description": "{ requests:[ SpendRequest ] } — newest first, at most 200; no paging parameters",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "requests": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/SpendRequest"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "SpendRequest"
    ],
    "x-catalogue-group": "finance-budgets"
   },
   "post": {
    "operationId": "post_parser_finance_spend_requests",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Request spend against a budget.",
    "description": "Request, as the catalogue words it: The SpendRequestCreate object: { budgetId:integer, amount:number } required; currency?:string, description?:string, vendor?:string.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: not deduplicated: every call records another pending request\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "requestBody": {
     "description": "The SpendRequestCreate object: { budgetId:integer, amount:number } required; currency?:string, description?:string, vendor?:string.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/SpendRequestCreate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/SpendRequest"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "SpendRequest",
     "SpendRequestCreate"
    ],
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/spend-requests/{request_id}/{action}": {
   "post": {
    "operationId": "post_parser_finance_spend_requests_request_id_action",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Approve, reject, fulfil or cancel a spend request.",
    "description": "Request, as the catalogue words it: The SpendRequestDecision object. action is in the path: approve | reject | fulfill | cancel. reject and cancel read { reason?:string }; approve and fulfill read no body.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + per-action permission, see guard (licensed)",
    "parameters": [
     {
      "name": "request_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     },
     {
      "name": "action",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Typed from the handler's signature: declared str in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "requestBody": {
     "description": "The SpendRequestDecision object. action is in the path: approve | reject | fulfill | cancel. reject and cancel read { reason?:string }; approve and fulfill read no body.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/SpendRequestDecision"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the SpendRequest",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/SpendRequest"
        }
       }
      }
     },
     "404": {
      "description": "'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"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + per-action permission, see guard (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "SpendRequest",
     "SpendRequestDecision"
    ],
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/systems": {
   "get": {
    "operationId": "get_parser_finance_systems",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Systems and subscriptions linked to budgets.",
    "description": "Reply, as the catalogue words it: 200 { systems:[ OwnedSystem ] } — most recently linked first; not paged\n\nGuard: scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "responses": {
     "200": {
      "description": "{ systems:[ OwnedSystem ] } — most recently linked first; not paged",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "systems": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/OwnedSystem"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "OwnedSystem"
    ],
    "x-catalogue-group": "finance-budgets"
   },
   "post": {
    "operationId": "post_parser_finance_systems",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Link a system to a budget.",
    "description": "Request, as the catalogue words it: The OwnedSystemClaim object: { budgetId?:integer, providerType?:string, externalProviderId?:integer, systemRef?:string, label?:string, kind?:string, ownerEmail?:string }.\n\nReply, as the catalogue words it: 200 the OwnedSystem | 403 | 404 with the reason\n\nGuard: for a scoped manager the owner is forced to the caller. Repeat: not deduplicated: every call links the system again as a new record\n\nAuth: Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "requestBody": {
     "description": "The OwnedSystemClaim object: { budgetId?:integer, providerType?:string, externalProviderId?:integer, systemRef?:string, label?:string, kind?:string, ownerEmail?:string }.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/OwnedSystemClaim"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the OwnedSystem",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/OwnedSystem"
        }
       }
      }
     },
     "403": {
      "description": "No further text in the catalogue."
     },
     "404": {
      "description": "with the reason"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "OwnedSystem",
     "OwnedSystemClaim"
    ],
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/systems/providers": {
   "get": {
    "operationId": "get_parser_finance_systems_providers",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Providers a system can be linked from.",
    "description": "Reply, as the catalogue words it: 200 { providers:[{ externalProviderId:integer, providerType:string, name:string, metering: live | derived | discovery, hasBilling:boolean }] }; not paged\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "responses": {
     "200": {
      "description": "{ providers:[{ externalProviderId:integer, providerType:string, name:string, metering: live | derived | discovery, hasBilling:boolean }] }; not paged",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "providers": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "externalProviderId": {
              "type": "integer"
             },
             "providerType": {
              "type": "string"
             },
             "name": {
              "type": "string"
             },
             "hasBilling": {
              "type": "boolean"
             }
            },
            "x-keys-not-described": [
             "metering"
            ]
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/systems/{system_id}": {
   "delete": {
    "operationId": "delete_parser_finance_systems_system_id",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Unlink a system from its budget.",
    "description": "Request, as the catalogue words it: No body. system_id is an integer in the path.\n\nReply, as the catalogue words it: 200 { success:true } | 403 | 404 'System not found'\n\nGuard: Repeat: a second call answers 404 'System not found'\n\nAuth: Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "parameters": [
     {
      "name": "system_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the handler's signature: declared int in the handler's signature.",
      "x-type-basis": "parser"
     }
    ],
    "responses": {
     "200": {
      "description": "{ success:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "403": {
      "description": "No further text in the catalogue."
     },
     "404": {
      "description": "'System not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/parser/finance/cloud/anomalies": {
   "get": {
    "operationId": "get_parser_finance_cloud_anomalies",
    "tags": [
     "Finance: Budgets, Transfers & Spend Requests"
    ],
    "summary": "Month-over-month cloud cost spikes on linked systems.",
    "description": "Reply, as the catalogue words it: 200 { anomalies:[{ system:string, provider:string, budgetId:integer|null, currentMonth:number, previousMonth:number, changePct:number, delta:number, severity: high | medium }] }; not paged\n\nAuth: Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "responses": {
     "200": {
      "description": "{ anomalies:[{ system:string, provider:string, budgetId:integer|null, currentMonth:number, previousMonth:number, changePct:number, delta:number, severity: high | medium }] }; not paged",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "anomalies": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "system": {
              "type": "string"
             },
             "provider": {
              "type": "string"
             },
             "budgetId": {
              "type": [
               "integer",
               "null"
              ]
             },
             "currentMonth": {
              "type": "number"
             },
             "previousMonth": {
              "type": "number"
             },
             "changePct": {
              "type": "number"
             },
             "delta": {
              "type": "number"
             }
            },
            "x-keys-not-described": [
             "severity"
            ]
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "x-permissions-named": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "x-licensed": true,
    "x-catalogue-group": "finance-budgets"
   }
  },
  "/ai/score-candidate": {
   "post": {
    "operationId": "post_ai_score_candidate",
    "tags": [
     "Local AI"
    ],
    "summary": "Score a candidate against a job description.",
    "description": "Request, as the catalogue words it: { resumeText } required; jobTitle, jobDescription, requirements[], competencies optional. resumeText truncated to 3000 chars, jobDescription to 1000.\n\nReply, as the catalogue words it: 200 { success:true, matchScore:int 0-100 (clamped), aiSummary } | 400 { success:false, error:'No resume text provided' } | 500 { success:false, error:'AI processing failed' }\n\nGuard: requirePermission(['view_recruitment','manage_recruitment']); M2M x-internal-service-token required or 401\n\nAuth: Session + view_recruitment | manage_recruitment",
    "requestBody": {
     "description": "{ resumeText } required; jobTitle, jobDescription, requirements[], competencies optional. resumeText truncated to 3000 chars, jobDescription to 1000.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success:true, matchScore:int 0-100 (clamped), aiSummary }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "matchScore",
          "aiSummary"
         ]
        }
       }
      }
     },
     "400": {
      "description": "{ success:false, error:'No resume text provided' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "500": {
      "description": "{ success:false, error:'AI processing failed' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_recruitment | manage_recruitment",
    "x-permissions-named": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/score-candidate-stream": {
   "post": {
    "operationId": "post_ai_score_candidate_stream",
    "tags": [
     "Local AI"
    ],
    "summary": "Same, streamed incrementally into the UI.",
    "description": "Request, as the catalogue words it: { resumeText } required; jobTitle, jobDescription, requirements[] optional.\n\nReply, as the catalogue words it: 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' }\n\nGuard: requirePermission(['view_recruitment','manage_recruitment']); aborts Ollama generation on client disconnect; M2M token or 401\n\nAuth: Session + view_recruitment | manage_recruitment",
    "requestBody": {
     "description": "{ resumeText } required; jobTitle, jobDescription, requirements[] optional.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "text/plain chunked stream (first line 'SCORE: <n>', then SUMMARY/STRENGTHS/GAPS/VERDICT)"
     },
     "400": {
      "description": "{ success:false, error:'No resume text provided' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "500": {
      "description": "{ success:false, error:'AI processing failed' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_recruitment | manage_recruitment",
    "x-permissions-named": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/generate-competencies": {
   "post": {
    "operationId": "post_ai_generate_competencies",
    "tags": [
     "Local AI"
    ],
    "summary": "Generate scorecard competencies for a role.",
    "description": "Request, as the catalogue words it: { jobTitle } required; jobDescription optional (truncated 800 chars).\n\nReply, as the catalogue words it: 200 { competencies: string[] } (deduped, sliced to 12) | 400 { error:'jobTitle is required' } | 500 { error:'AI processing failed' }\n\nGuard: requirePermission(['view_recruitment','manage_recruitment']); M2M token or 401\n\nAuth: Session + view_recruitment | manage_recruitment",
    "requestBody": {
     "description": "{ jobTitle } required; jobDescription optional (truncated 800 chars).",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ competencies: string[] } (deduped, sliced to 12)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "competencies": {
           "type": "array",
           "items": {
            "type": "string"
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "{ error:'jobTitle is required' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "500": {
      "description": "{ error:'AI processing failed' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_recruitment | manage_recruitment",
    "x-permissions-named": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/generate-interview-questions": {
   "post": {
    "operationId": "post_ai_generate_interview_questions",
    "tags": [
     "Local AI"
    ],
    "summary": "Generate structured interview questions.",
    "description": "Request, as the catalogue words it: { jobTitle } required; department, jobDescription, competencies[], requirements[], seniority, minExperience, employmentType optional.\n\nReply, as the catalogue words it: 200 { questions:[{ questionText, category }] } | 400 { error:'jobTitle is required' } | 500 { error:'Interview question generation failed' }\n\nGuard: requirePermission(['view_recruitment','manage_recruitment']); M2M token or 401\n\nAuth: Session + view_recruitment | manage_recruitment",
    "requestBody": {
     "description": "{ jobTitle } required; department, jobDescription, competencies[], requirements[], seniority, minExperience, employmentType optional.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ questions:[{ questionText, category }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "questions": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "questionText",
             "category"
            ]
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "{ error:'jobTitle is required' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "500": {
      "description": "{ error:'Interview question generation failed' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_recruitment | manage_recruitment",
    "x-permissions-named": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/generate-offer-template": {
   "post": {
    "operationId": "post_ai_generate_offer_template",
    "tags": [
     "Local AI"
    ],
    "summary": "Draft an offer template.",
    "description": "Request, as the catalogue words it: No required field. jobTitle, department, location, salary, salaryRange, currency, managerName, jobDescription, competencies[], employmentType, seniority, workMode all optional.\n\nReply, as the catalogue words it: 200 { body } (square-bracket fill-ins stripped/normalized; only {{Curly}} merge fields kept) | 500 { error:'Offer template generation failed' }\n\nGuard: requirePermission(['view_recruitment','manage_recruitment']); M2M token or 401\n\nAuth: Session + view_recruitment | manage_recruitment",
    "requestBody": {
     "description": "No required field. jobTitle, department, location, salary, salaryRange, currency, managerName, jobDescription, competencies[], employmentType, seniority, workMode all optional.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ body } (square-bracket fill-ins stripped/normalized; only {{Curly}} merge fields kept)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "500": {
      "description": "{ error:'Offer template generation failed' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_recruitment | manage_recruitment",
    "x-permissions-named": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/calibrate-scorecards": {
   "post": {
    "operationId": "post_ai_calibrate_scorecards",
    "tags": [
     "Local AI"
    ],
    "summary": "Calibrate scoring across interviewers.",
    "description": "Request, as the catalogue words it: { scorecards: [] } required and non-empty; candidateName, jobTitle optional.\n\nReply, as the catalogue words it: 200 { executiveSummary, consensusLevel, conflicts:[{topic,description}], biasFlags:string[], recommendation } | 400 { error:'No scorecards provided' } | 500 { error:'Calibration analysis failed' }\n\nGuard: requirePermission(['view_recruitment','manage_recruitment']); M2M token or 401\n\nAuth: Session + view_recruitment | manage_recruitment",
    "requestBody": {
     "description": "{ scorecards: [] } required and non-empty; candidateName, jobTitle optional.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ executiveSummary, consensusLevel, conflicts:[{topic,description}], biasFlags:string[], recommendation }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "conflicts": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "topic",
             "description"
            ]
           }
          },
          "biasFlags": {
           "type": "array",
           "items": {
            "type": "string"
           }
          }
         },
         "x-keys-not-described": [
          "executiveSummary",
          "consensusLevel",
          "recommendation"
         ]
        }
       }
      }
     },
     "400": {
      "description": "{ error:'No scorecards provided' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "500": {
      "description": "{ error:'Calibration analysis failed' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_recruitment | manage_recruitment",
    "x-permissions-named": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/pipeline-insights": {
   "post": {
    "operationId": "post_ai_pipeline_insights",
    "tags": [
     "Local AI"
    ],
    "summary": "Summarise pipeline health.",
    "description": "Request, as the catalogue words it: { summary } (numeric pipeline snapshot object). Missing summary short-circuits to empty.\n\nReply, as the catalogue words it: 200 { insights:[{ title, desc, severity }] } — FAILS OPEN: empty summary or any error returns 200 { insights:[] }, no 4xx/5xx\n\nGuard: requirePermission(['view_recruitment','manage_recruitment']); M2M token or 401\n\nAuth: Session + view_recruitment | manage_recruitment",
    "requestBody": {
     "description": "{ summary } (numeric pipeline snapshot object). Missing summary short-circuits to empty.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ insights:[{ title, desc, severity }] } — FAILS OPEN: empty summary or any error returns 200 { insights:[] }, no 4xx/5xx",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "insights": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "title",
             "desc",
             "severity"
            ]
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_recruitment | manage_recruitment",
    "x-permissions-named": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/chat": {
   "post": {
    "operationId": "post_ai_chat",
    "tags": [
     "Local AI"
    ],
    "summary": "General recruitment assistant chat.",
    "description": "Request, as the catalogue words it: { query } required (branches to template-mode on keywords like 'template'/'offer letter'); context optional (PII-redacted before prompt).\n\nReply, as the catalogue words it: 200 { response } (or safe refusal string if output looks like an injection-echo) | 500 { error:'AI engine unreachable' }\n\nGuard: requirePermission(['view_recruitment','manage_recruitment']); context PII-redacted (SSN/card/IBAN/email/phone); injection-echo output firewall; M2M token or 401\n\nAuth: Session + view_recruitment | manage_recruitment",
    "requestBody": {
     "description": "{ query } required (branches to template-mode on keywords like 'template'/'offer letter'); context optional (PII-redacted before prompt).",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ response } (or safe refusal string if output looks like an injection-echo)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "500": {
      "description": "{ error:'AI engine unreachable' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_recruitment | manage_recruitment",
    "x-permissions-named": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/refine-text": {
   "post": {
    "operationId": "post_ai_refine_text",
    "tags": [
     "Local AI"
    ],
    "summary": "Rewrite or refine drafted text.",
    "description": "Request, as the catalogue words it: { text } required.\n\nReply, as the catalogue words it: 200 { refinedText } | 400 { error:'No text provided' } | 500 { error:'Refinement failed' }\n\nGuard: requirePermission(['view_recruitment','manage_recruitment']); M2M token or 401\n\nAuth: Session + view_recruitment | manage_recruitment",
    "requestBody": {
     "description": "{ text } required.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ refinedText }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "{ error:'No text provided' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "500": {
      "description": "{ error:'Refinement failed' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_recruitment | manage_recruitment",
    "x-permissions-named": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/analyze-report": {
   "post": {
    "operationId": "post_ai_analyze_report",
    "tags": [
     "Local AI"
    ],
    "summary": "Narrative analysis of a compliance/reporting dataset.",
    "description": "Request, as the catalogue words it: { reportData } required (object: metadata, users[], groups[], workflows[], tasks[], logs[], frameworkDetails). Minimized server-side before prompt.\n\nReply, as the catalogue words it: 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\n\nGuard: requirePermission('view_reporting'); aborts Ollama generation on client disconnect; M2M token or 401\n\nAuth: Session + view_reporting",
    "requestBody": {
     "description": "{ reportData } required (object: metadata, users[], groups[], workflows[], tasks[], logs[], frameworkDetails). Minimized server-side before prompt.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "text/plain chunked stream (<think>...</think> then JSON: executiveSummary, riskLevel, metrics, anomalies[], recommendedActions[])"
     },
     "400": {
      "description": "{ success:false, error:'No report data provided' }. On AI error FAILS OPEN: streams a fallback JSON with 200",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting",
    "x-permissions-named": [
     "view_reporting"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/draft-justification": {
   "post": {
    "operationId": "post_ai_draft_justification",
    "tags": [
     "Local AI"
    ],
    "summary": "Draft a compliance justification.",
    "description": "Request, as the catalogue words it: { controlId, controlTitle, rawData, notes } — all optional (no server-side required-field validation).\n\nReply, as the catalogue words it: 200 { justification } | 500 { error:'Failed to draft justification note' }\n\nGuard: requirePermission('manage_compliance'); M2M token or 401\n\nAuth: Session + manage_compliance",
    "requestBody": {
     "description": "{ controlId, controlTitle, rawData, notes } — all optional (no server-side required-field validation).",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ justification }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "500": {
      "description": "{ error:'Failed to draft justification note' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/draft-workflow": {
   "post": {
    "operationId": "post_ai_draft_workflow",
    "tags": [
     "Local AI"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: { description } required (non-empty string); workflowType, palette[] optional\n\nReply, as the catalogue words it: 200 a list of steps drawn only from the offered palette | 400 missing description / 'No licensed step types to draft from.'\n\nGuard: 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\n\nAuth: Session + manage_workflows",
    "requestBody": {
     "description": "{ description } required (non-empty string); workflowType, palette[] optional",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "a list of steps drawn only from the offered palette"
     },
     "400": {
      "description": "missing description / 'No licensed step types to draft from.'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_workflows",
    "x-permissions-named": [
     "manage_workflows"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/extract-resume-fields": {
   "post": {
    "operationId": "post_ai_extract_resume_fields",
    "tags": [
     "Local AI"
    ],
    "summary": "Extract structured, filterable fields from resume text for recruiter-editable tags. Not a decision input.",
    "description": "Request, as the catalogue words it: { resumeText } required\n\nReply, as the catalogue words it: 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\n\nAuth: Session + view_recruitment | manage_recruitment",
    "requestBody": {
     "description": "{ resumeText } required",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ skills (up to 40), titles (up to 20), employers (up to 20), education (up to 15), totalYears (number or null), seniority (string or null) }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "resumeText is required"
     },
     "500": {
      "description": "AI processing failed"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_recruitment | manage_recruitment",
    "x-permissions-named": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/generate-job-description": {
   "post": {
    "operationId": "post_ai_generate_job_description",
    "tags": [
     "Local AI"
    ],
    "summary": "Draft a job description.",
    "description": "Request, as the catalogue words it: { jobTitle } required; department?, seniority?, employmentType?, workMode?, location?, competencies?\n\nReply, as the catalogue words it: 200 { description } | 400 jobTitle is required | 422 the model produced no usable description | 500 AI processing failed\n\nAuth: Session + view_recruitment | manage_recruitment",
    "requestBody": {
     "description": "{ jobTitle } required; department?, seniority?, employmentType?, workMode?, location?, competencies?",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ description }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "jobTitle is required"
     },
     "422": {
      "description": "the model produced no usable description"
     },
     "500": {
      "description": "AI processing failed"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_recruitment | manage_recruitment",
    "x-permissions-named": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/draft-candidate-message": {
   "post": {
    "operationId": "post_ai_draft_candidate_message",
    "tags": [
     "Local AI"
    ],
    "summary": "Draft an email to a candidate.",
    "description": "Request, as the catalogue words it: { candidateName, jobTitle, stage, purpose, companyName } — all optional; a missing field is replaced by a neutral default in the prompt\n\nReply, as the catalogue words it: 200 { subject (at most 200 characters), body } — either may be an empty string when the model returns nothing usable | 500 { error:'AI processing failed' }\n\nGuard: writes nothing, so it is safe to repeat (each call drafts again and may word it differently)\n\nAuth: Session + manage_recruitment",
    "requestBody": {
     "description": "{ candidateName, jobTitle, stage, purpose, companyName } — all optional; a missing field is replaced by a neutral default in the prompt",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ subject (at most 200 characters), body } — either may be an empty string when the model returns nothing usable",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "500": {
      "description": "{ error:'AI processing failed' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_recruitment",
    "x-permissions-named": [
     "manage_recruitment"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/chat-stream": {
   "post": {
    "operationId": "post_ai_chat_stream",
    "tags": [
     "Local AI"
    ],
    "summary": "The recruitment assistant chat, streamed.",
    "description": "Request, as the catalogue words it: { query } required (non-empty string); context?\n\nReply, as the catalogue words it: 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\n\nAuth: Session + view_recruitment | manage_recruitment",
    "requestBody": {
     "description": "{ query } required (non-empty string); context?",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "when query is missing or empty"
     },
     "500": {
      "description": "when the model cannot be reached before the stream starts"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_recruitment | manage_recruitment",
    "x-permissions-named": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/parse-log-query": {
   "post": {
    "operationId": "post_ai_parse_log_query",
    "tags": [
     "Local AI"
    ],
    "summary": "Turn a plain-English question into audit-log filters.",
    "description": "Request, as the catalogue words it: { query } required (non-empty string; the first 500 characters are used); actions? (the action names the filter may pick from)\n\nReply, as the catalogue words it: 200 { level, userEmail, daysAgo, includeActions, excludeActions, q } — unset filters are omitted | 400 when query is missing or empty | 500 AI processing failed\n\nAuth: Session + view_logs",
    "requestBody": {
     "description": "{ query } required (non-empty string; the first 500 characters are used); actions? (the action names the filter may pick from)",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ level, userEmail, daysAgo, includeActions, excludeActions, q } — unset filters are omitted",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "when query is missing or empty"
     },
     "500": {
      "description": "AI processing failed"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_logs",
    "x-permissions-named": [
     "view_logs"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/parse-cloud-query": {
   "post": {
    "operationId": "post_ai_parse_cloud_query",
    "tags": [
     "Local AI"
    ],
    "summary": "Turn a plain-English question into a cloud-map search.",
    "description": "Request, as the catalogue words it: { query } required (non-empty string; the first 500 characters are used); types? (the resource types the filter may pick from)\n\nReply, as the catalogue words it: 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\n\nAuth: Session + view_cloud_infrastructure",
    "requestBody": {
     "description": "{ query } required (non-empty string; the first 500 characters are used); types? (the resource types the filter may pick from)",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ types, minSeverity (critical | high | medium | low), toxic, hasAttackPath, text } — unset filters are omitted",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "when query is missing or empty"
     },
     "500": {
      "description": "AI processing failed"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_cloud_infrastructure",
    "x-permissions-named": [
     "view_cloud_infrastructure"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/detect-toxic-combos": {
   "post": {
    "operationId": "post_ai_detect_toxic_combos",
    "tags": [
     "Local AI"
    ],
    "summary": "Flag risky permission combinations in cloud findings.",
    "description": "Request, as the catalogue words it: { items:[] (the first 40 are used), existingPaths?:[] (the first 25 are used) }\n\nReply, as the catalogue words it: 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\n\nAuth: Session + view_cloud_infrastructure",
    "requestBody": {
     "description": "{ items:[] (the first 40 are used), existingPaths?:[] (the first 25 are used) }",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "combos": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "title",
             "severity",
             "refs",
             "why",
             "remediation"
            ]
           }
          }
         }
        }
       }
      }
     },
     "500": {
      "description": "AI processing failed"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_cloud_infrastructure",
    "x-permissions-named": [
     "view_cloud_infrastructure"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/prioritize-findings": {
   "post": {
    "operationId": "post_ai_prioritize_findings",
    "tags": [
     "Local AI"
    ],
    "summary": "Suggest an order to work cloud findings in.",
    "description": "Request, as the catalogue words it: { items:[] (the first 40 are used), counts? }\n\nReply, as the catalogue words it: 200 { summary, topFocus:[{ ref, why }] } — at most 3 entries in topFocus | 500 AI processing failed\n\nAuth: Session + view_cloud_infrastructure",
    "requestBody": {
     "description": "{ items:[] (the first 40 are used), counts? }",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ summary, topFocus:[{ ref, why }] } — at most 3 entries in topFocus",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "topFocus": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "ref",
             "why"
            ]
           }
          }
         },
         "x-keys-not-described": [
          "summary"
         ]
        }
       }
      }
     },
     "500": {
      "description": "AI processing failed"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_cloud_infrastructure",
    "x-permissions-named": [
     "view_cloud_infrastructure"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/explain-finding": {
   "post": {
    "operationId": "post_ai_explain_finding",
    "tags": [
     "Local AI"
    ],
    "summary": "Explain a Security Lab finding in plain language.",
    "description": "Request, as the catalogue words it: { title, severity, target, evidence, mitigation, cvssScore } — a title or evidence is required\n\nReply, as the catalogue words it: 200 { explanation, businessImpact, steps:[] (up to 8) } | 400 when both title and evidence are missing | 500 AI processing failed\n\nAuth: Session + view_security_findings | view_cloud_infrastructure",
    "requestBody": {
     "description": "{ title, severity, target, evidence, mitigation, cvssScore } — a title or evidence is required",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ explanation, businessImpact, steps:[] (up to 8) }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "when both title and evidence are missing"
     },
     "500": {
      "description": "AI processing failed"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | view_cloud_infrastructure",
    "x-permissions-named": [
     "view_cloud_infrastructure",
     "view_security_findings"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/model-status": {
   "get": {
    "operationId": "get_ai_model_status",
    "tags": [
     "Local AI"
    ],
    "summary": "Whether the selected model is pulled and warm (used by the Settings model switch).",
    "description": "Request, as the catalogue words it: Query: ?model= required (must be on the allow-list); ?previous= optional (the model being switched away from)\n\nReply, as the catalogue words it: 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'\n\nGuard: read-only, except that it loads an already-downloaded model into memory so the next request is fast\n\nAuth: Session + manage_security",
    "responses": {
     "200": {
      "description": "an AiModelStatus (typed under Response Objects) { model, pulled, ready, state:'ready'|'loading'|'pulling'|'engine_unreachable', reachable, previousResident (boolean or null), consumers }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AiModelStatus"
        }
       }
      }
     },
     "400": {
      "description": "'model query param is required' | 'model is not on the allowlist'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-typed-objects": [
     "AiModelStatus"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/model-capability": {
   "get": {
    "operationId": "get_ai_model_capability",
    "tags": [
     "Local AI"
    ],
    "summary": "The instance's cores and memory, and which allow-listed models it can run.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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' }\n\nAuth: Session + manage_security",
    "responses": {
     "200": {
      "description": "an AiModelCapability (typed under Response Objects) { box:{ cores, ramGB, gpu, cpuParamCap }, models:[{ id, label, sizeB, ramGB, fits:boolean, reason }], defaultModel }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AiModelCapability"
        }
       }
      }
     },
     "500": {
      "description": "{ error:'capability check failed' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-typed-objects": [
     "AiModelCapability"
    ],
    "x-catalogue-group": "ai"
   }
  },
  "/ai/health": {
   "get": {
    "operationId": "get_ai_health",
    "tags": [
     "Local AI"
    ],
    "summary": "AI engine status and loaded model.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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 }\n\nGuard: read status, not the HTTP code: an unreachable engine still answers 200 with status 'degraded'\n\nAuth: Session",
    "responses": {
     "200": {
      "description": "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 }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-catalogue-group": "ai"
   }
  },
  "/user-management/logs": {
   "get": {
    "operationId": "get_user_management_logs",
    "tags": [
     "Audit Logs & Device Inventory"
    ],
    "summary": "Query the central audit log (filter by action, level, actor, date).",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 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\n\nGuard: paged by page and limit, not offset: row n starts at page × limit\n\nAuth: Session + view_logs",
    "parameters": [
     {
      "name": "page",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 0,
       "minimum": 0
      },
      "description": "Zero-based. A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 25,
       "minimum": 1,
       "maximum": 500
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "level",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "userEmail",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "includeActions",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "excludeActions",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "daysAgo",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "correlationId",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "q",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "deep",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "1"
       ]
      },
      "description": "Search the payload too. Read only together with q."
     }
    ],
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AuditLogPage"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_logs",
    "x-permissions-named": [
     "view_logs"
    ],
    "x-paging-parameters": [
     "limit",
     "page"
    ],
    "x-typed-objects": [
     "AuditLog",
     "AuditLogPage"
    ],
    "x-catalogue-group": "logs-devices"
   },
   "delete": {
    "operationId": "delete_user_management_logs",
    "tags": [
     "Audit Logs & Device Inventory"
    ],
    "summary": "Always refused: audit logs are append-only.",
    "description": "Request, as the catalogue words it: No body (any body ignored). Audit logs are append-only; deletion by id or purgeAll is not a capability.\n\nReply, as the catalogue words it: 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).\n\nGuard: 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\n\nAuth: Session + manage_logs",
    "responses": {
     "default": {
      "description": "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).",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_logs",
    "x-permissions-named": [
     "manage_logs"
    ],
    "x-catalogue-group": "logs-devices"
   }
  },
  "/user-management/logs/verify": {
   "get": {
    "operationId": "get_user_management_logs_verify",
    "tags": [
     "Audit Logs & Device Inventory"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + view_logs",
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AuditChainVerification"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_logs",
    "x-permissions-named": [
     "view_logs"
    ],
    "x-typed-objects": [
     "AuditChainVerification"
    ],
    "x-catalogue-group": "logs-devices"
   }
  },
  "/user-management/logs/export": {
   "get": {
    "operationId": "get_user_management_logs_export",
    "tags": [
     "Audit Logs & Device Inventory"
    ],
    "summary": "Export the audit log as CSV or JSON for an auditor.",
    "description": "Request, as the catalogue words it: Query: level?, userEmail?, includeActions?, excludeActions?, daysAgo?, format? ('csv'|'json'; default follows Accept header). No JSON body.\n\nReply, as the catalogue words it: 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\n\nGuard: view_logs; the export itself is recorded as an AUDIT_LOG_EXPORTED (warning) entry with the filters used — best-effort, never blocks the download\n\nAuth: Session + view_logs",
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_logs",
    "x-permissions-named": [
     "view_logs"
    ],
    "x-catalogue-group": "logs-devices"
   }
  },
  "/user-management/devices": {
   "get": {
    "operationId": "get_user_management_devices",
    "tags": [
     "Audit Logs & Device Inventory",
     "Zonitix Agent (Endpoints)",
     "Device Management"
    ],
    "summary": "List hardware inventory.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 200 [ Device ] with Owner:{ id, firstName, lastName, email } — a bare array, no total | with ?view=list: { items[], total, page, pageSize, kpis, staleDays, truncated }\n\nGuard: two paging conventions on one path: limit/offset for the default view, page/pageSize for ?view=list\n\nAuth: Session + view_devices",
    "parameters": [
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 200,
       "minimum": 1,
       "maximum": 1000
      },
      "description": "Default view only. A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "offset",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 0,
       "minimum": 0
      },
      "description": "Default view only. A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "enrolled",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "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",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "true"
       ]
      },
      "description": "Default view: include enrolments awaiting approval."
     },
     {
      "name": "retired",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "true"
       ]
      },
      "description": "Include retired devices."
     },
     {
      "name": "view",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "list"
       ]
      },
      "description": "list answers the filtered, sorted, paged fleet list. The parameters below are read only with view=list."
     },
     {
      "name": "page",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 1,
       "minimum": 1,
       "maximum": 1000000
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "pageSize",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 50,
       "minimum": 1,
       "maximum": 500
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "sort",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "default": "last"
      }
     },
     {
      "name": "dir",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "asc",
        "desc"
       ]
      }
     },
     {
      "name": "q",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "A longer value is cut to 200 characters."
     },
     {
      "name": "platform",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "status",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "updates",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "risk",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "assign",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "lifecycle",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "compliance",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "issue",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "silentDays",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "[ Device ] with Owner:{ id, firstName, lastName, email } — a bare array, no total | with ?view=list: { items[], total, page, pageSize, kpis, staleDays, truncated }",
      "content": {
       "application/json": {
        "schema": {
         "anyOf": [
          {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/Device"
           }
          },
          {
           "type": "object",
           "properties": {
            "items": {
             "type": "array"
            },
            "total": {
             "type": "integer"
            },
            "page": {
             "type": "integer"
            },
            "pageSize": {
             "type": "integer"
            },
            "kpis": {
             "type": "object"
            }
           },
           "x-keys-not-described": [
            "staleDays",
            "truncated"
           ]
          }
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-paging-parameters": [
     "limit",
     "offset",
     "page",
     "pageSize"
    ],
    "x-typed-objects": [
     "Device"
    ],
    "x-catalogue-group": "logs-devices"
   },
   "post": {
    "operationId": "post_user_management_devices",
    "tags": [
     "Audit Logs & Device Inventory",
     "Device Management"
    ],
    "summary": "Register a device in inventory.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 201 the Device record as created (JSON). Writes a DEVICE_CREATED audit entry with actor from x-user header.\n\nGuard: 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\n\nAuth: Session + manage_devices",
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DeviceCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "the Device record as created (JSON). Writes a DEVICE_CREATED audit entry with actor from x-user header.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Device"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-typed-objects": [
     "Device"
    ],
    "x-catalogue-group": "logs-devices"
   }
  },
  "/agent/enroll": {
   "post": {
    "operationId": "post_agent_enroll",
    "tags": [
     "Zonitix Agent (Endpoints)"
    ],
    "summary": "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).",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 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)\n\nGuard: 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\n\nAuth: Enrolment token",
    "requestBody": {
     "description": "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",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AgentEnrollRequest"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "an AgentEnrollReply: { deviceId }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AgentEnrollReply"
        }
       }
      }
     },
     "400": {
      "description": "missing field, invalid/non-P-256 key, or a serialNumber over 255 characters or containing a NUL"
     },
     "401": {
      "description": "invalid/expired/exhausted token"
     },
     "403": {
      "description": "the serial belongs to a retired device (an administrator must return it to service first)"
     },
     "409": {
      "description": "already enrolled with a different signing key (needs admin reEnrollAllowed)"
     }
    },
    "security": [
     {
      "namedInXAuth": []
     }
    ],
    "x-auth": "Enrolment token",
    "x-permissions-named": [],
    "x-typed-objects": [
     "AgentEnrollRequest",
     "AgentEnrollReply"
    ],
    "x-catalogue-group": "agent"
   }
  },
  "/agent/heartbeat": {
   "post": {
    "operationId": "post_agent_heartbeat",
    "tags": [
     "Zonitix Agent (Endpoints)"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Device signature (P-256)",
    "parameters": [
     {
      "name": "x-zx-device-id",
      "in": "header",
      "required": true,
      "schema": {
       "type": "integer",
       "minimum": 1
      },
      "description": "The device id the enrolment answered."
     },
     {
      "name": "x-zx-timestamp",
      "in": "header",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "When the request was signed."
     },
     {
      "name": "x-zx-nonce",
      "in": "header",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "A value never used before by this device."
     },
     {
      "name": "x-zx-signature",
      "in": "header",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "The device key's signature over the request."
     }
    ],
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AgentHeartbeatRequest"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AgentHeartbeatReply"
        }
       }
      }
     },
     "401": {
      "description": "missing headers / stale timestamp / unknown device / bad signature / replayed nonce"
     }
    },
    "security": [
     {
      "namedInXAuth": []
     }
    ],
    "x-auth": "Device signature (P-256)",
    "x-permissions-named": [],
    "x-typed-objects": [
     "AgentHeartbeatRequest",
     "AgentHeartbeatReply"
    ],
    "x-catalogue-group": "agent"
   }
  },
  "/agent/pkg": {
   "get": {
    "operationId": "get_agent_pkg",
    "tags": [
     "Zonitix Agent (Endpoints)"
    ],
    "summary": "Download the macOS agent installer package (?v= selects a version).",
    "description": "Request, as the catalogue words it: Query: ?v= optional version; omitted, the current hosted package\n\nReply, as the catalogue words it: 200 the package bytes (application/octet-stream, Content-Disposition attachment, X-Zx-Pkg-Sha256 header) | 404 { error:'No macos agent … is hosted' }\n\nGuard: the package carries no secret; the agent verifies the vendor release signature before it updates itself\n\nAuth: Public (rate-limited)",
    "parameters": [
     {
      "name": "v",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "A hosted version. Left out: the current hosted one."
     }
    ],
    "responses": {
     "200": {
      "description": "the package bytes (application/octet-stream, Content-Disposition attachment, X-Zx-Pkg-Sha256 header)"
     },
     "404": {
      "description": "{ error:'No macos agent … is hosted' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "agent"
   }
  },
  "/agent/linux/binary": {
   "get": {
    "operationId": "get_agent_linux_binary",
    "tags": [
     "Zonitix Agent (Endpoints)"
    ],
    "summary": "Download the Linux agent binary.",
    "description": "Request, as the catalogue words it: Query: ?v= optional version\n\nReply, as the catalogue words it: 200 the binary (application/octet-stream, X-Zx-Pkg-Sha256 header) | 404 { error:'No linux agent … is hosted' }\n\nAuth: Public (rate-limited)",
    "parameters": [
     {
      "name": "v",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "A hosted version. Left out: the current hosted one."
     }
    ],
    "responses": {
     "200": {
      "description": "the binary (application/octet-stream, X-Zx-Pkg-Sha256 header)"
     },
     "404": {
      "description": "{ error:'No linux agent … is hosted' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "agent"
   }
  },
  "/agent/linux/install": {
   "get": {
    "operationId": "get_agent_linux_install",
    "tags": [
     "Zonitix Agent (Endpoints)"
    ],
    "summary": "The one-line Linux install script.",
    "description": "Request, as the catalogue words it: Query: ?v= optional version; an unknown version is ignored and the current one is used\n\nReply, as the catalogue words it: 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\n\nGuard: the enrolment token baked into the script is scoped to your instance and reusable\n\nAuth: Public (rate-limited)",
    "parameters": [
     {
      "name": "v",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "A hosted version. Left out: the current hosted one."
     }
    ],
    "responses": {
     "200": {
      "description": "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"
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "agent"
   }
  },
  "/agent/windows/binary": {
   "get": {
    "operationId": "get_agent_windows_binary",
    "tags": [
     "Zonitix Agent (Endpoints)"
    ],
    "summary": "Download the Windows agent MSI.",
    "description": "Request, as the catalogue words it: Query: ?v= optional version\n\nReply, as the catalogue words it: 200 the MSI (application/octet-stream, X-Zx-Pkg-Sha256 header) | 404 { error:'No windows agent … is hosted' }\n\nAuth: Public (rate-limited)",
    "parameters": [
     {
      "name": "v",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "A hosted version. Left out: the current hosted one."
     }
    ],
    "responses": {
     "200": {
      "description": "the MSI (application/octet-stream, X-Zx-Pkg-Sha256 header)"
     },
     "404": {
      "description": "{ error:'No windows agent … is hosted' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "agent"
   }
  },
  "/agent/windows/install": {
   "get": {
    "operationId": "get_agent_windows_install",
    "tags": [
     "Zonitix Agent (Endpoints)"
    ],
    "summary": "The elevated-PowerShell Windows install one-liner.",
    "description": "Request, as the catalogue words it: Query: ?v= optional version; an unknown version is ignored and the current one is used\n\nReply, as the catalogue words it: 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\n\nAuth: Public (rate-limited)",
    "parameters": [
     {
      "name": "v",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "A hosted version. Left out: the current hosted one."
     }
    ],
    "responses": {
     "200": {
      "description": "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"
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "agent"
   }
  },
  "/agent/escrow/recovery-key": {
   "post": {
    "operationId": "post_agent_escrow_recovery_key",
    "tags": [
     "Zonitix Agent (Endpoints)"
    ],
    "summary": "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).",
    "description": "Request, as the catalogue words it: An AgentRecoveryKeyEscrow: { keyType:'bitlocker'|'filevault', recoveryKey } required; volumeId optional (at most 255 characters). Signed with the same x-zx-* headers as the heartbeat\n\nReply, as the catalogue words it: 200 { ok:true, escrowed:true } | 400 { error:'keyType (bitlocker|filevault) and recoveryKey are required' } | 401 signature, timestamp or nonce rejected\n\nGuard: 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)\n\nAuth: Device signature (P-256)",
    "parameters": [
     {
      "name": "x-zx-device-id",
      "in": "header",
      "required": true,
      "schema": {
       "type": "integer",
       "minimum": 1
      },
      "description": "The device id the enrolment answered."
     },
     {
      "name": "x-zx-timestamp",
      "in": "header",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "When the request was signed."
     },
     {
      "name": "x-zx-nonce",
      "in": "header",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "A value never used before by this device."
     },
     {
      "name": "x-zx-signature",
      "in": "header",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "The device key's signature over the request."
     }
    ],
    "requestBody": {
     "description": "An AgentRecoveryKeyEscrow: { keyType:'bitlocker'|'filevault', recoveryKey } required; volumeId optional (at most 255 characters). Signed with the same x-zx-* headers as the heartbeat",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AgentRecoveryKeyEscrow"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok:true, escrowed:true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "escrowed": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "{ error:'keyType (bitlocker|filevault) and recoveryKey are required' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "401": {
      "description": "signature, timestamp or nonce rejected"
     }
    },
    "security": [
     {
      "namedInXAuth": []
     }
    ],
    "x-auth": "Device signature (P-256)",
    "x-permissions-named": [],
    "x-catalogue-group": "agent"
   }
  },
  "/agent/mdm-enroll-redeem": {
   "post": {
    "operationId": "post_agent_mdm_enroll_redeem",
    "tags": [
     "Zonitix Agent (Endpoints)"
    ],
    "summary": "Redeem an MDM enrolment code from your instance's /enroll page and receive the enrolment profile.",
    "description": "Request, as the catalogue words it: An AgentMdmEnrollRedeem: { code } required — at least 6 letters or digits; body limited to 4 KB\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Enrolment code",
    "requestBody": {
     "description": "An AgentMdmEnrollRedeem: { code } required — at least 6 letters or digits; body limited to 4 KB",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AgentMdmEnrollRedeem"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the enrolment profile (application/x-apple-aspen-config, attachment zonitix-enroll.mobileconfig)"
     },
     "400": {
      "description": "{ error } the code is too short, did not work, or MDM is not set up",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "502": {
      "description": "{ error } the enrolment service is not reachable",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "namedInXAuth": []
     }
    ],
    "x-auth": "Enrolment code",
    "x-permissions-named": [],
    "x-catalogue-group": "agent"
   }
  },
  "/user-management/devices/{id}": {
   "get": {
    "operationId": "get_user_management_devices_id",
    "tags": [
     "Device Management"
    ],
    "summary": "One device (CSV of the filtered list at /devices/export.csv).",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Session + view_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Device primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "the Device with Owner { id, firstName, lastName, email }; the agent's public key is never included",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Device"
        }
       }
      }
     },
     "404": {
      "description": "unknown device. The CSV export takes the ?view=list filters and is audited as DEVICE_EXPORT"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-typed-objects": [
     "Device"
    ],
    "x-catalogue-group": "device-management"
   },
   "delete": {
    "operationId": "delete_user_management_devices_id",
    "tags": [
     "Device Management"
    ],
    "summary": "Delete a device record. A repeat answers 404.",
    "description": "Request, as the catalogue words it: { reason } in the body, or ?reason — required, 3–255 characters\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: 404; the record is gone\n\nAuth: Session + manage_devices and manage_device_destructive + sudo",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Device primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ id, deleted: true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "deleted": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "id"
         ]
        }
       }
      }
     },
     "400": {
      "description": "No further text in the catalogue. | { 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices and manage_device_destructive + sudo",
    "x-permissions-named": [
     "manage_device_destructive",
     "manage_devices"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/lite": {
   "get": {
    "operationId": "get_user_management_devices_lite",
    "tags": [
     "Device Management"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: Query string, not a JSON body: ?q, ?limit, ?enrolled, ?ids.\n\nReply, as the catalogue words it: 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\n\nGuard: read-only and safe to repeat\n\nAuth: Session + view_devices",
    "parameters": [
     {
      "name": "q",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "A longer value is cut to 100 characters."
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 25,
       "minimum": 1,
       "maximum": 100
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "enrolled",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "true"
       ]
      },
      "description": "Agent-enrolled devices only."
     },
     {
      "name": "ids",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "One comma-separated list of at most 100 device ids. When sent, q, limit and enrolled are not read."
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "items": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/DeviceLite"
           }
          },
          "total": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "{ error } when ids is repeated instead of comma-separated, holds a value that is not a number, or names more than 100 ids",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-paging-parameters": [
     "limit"
    ],
    "x-typed-objects": [
     "DeviceLite"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/summary": {
   "get": {
    "operationId": "get_user_management_devices_summary",
    "tags": [
     "Device Management"
    ],
    "summary": "Fleet totals for the Overview page. No parameters.",
    "description": "Reply, as the catalogue words it: 200 { generatedAt, total, pending, byPlatform, stale, updates, coverage, belowMinOs, compliant, complianceUnknown, attention, retiredReappeared, osVersions, agentVersions, alerts, staleDays }\n\nAuth: Session + view_devices",
    "responses": {
     "200": {
      "description": "{ generatedAt, total, pending, byPlatform, stale, updates, coverage, belowMinOs, compliant, complianceUnknown, attention, retiredReappeared, osVersions, agentVersions, alerts, staleDays }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/{id}/effective-policy": {
   "get": {
    "operationId": "get_user_management_devices_id_effective_policy",
    "tags": [
     "Device Management"
    ],
    "summary": "The effective policy for one device (its Profiles & policy tab). Its groups are at /devices/:id/groups.",
    "description": "Reply, as the catalogue words it: 200 { platform, baseline, profiles: [{ id, name, payloadKeys }], effective, profileEnforcementOn, rollout: { target, frozen } } | 404 for an unknown device\n\nAuth: Session + view_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Device primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ platform, baseline, profiles: [{ id, name, payloadKeys }], effective, profileEnforcementOn, rollout: { target, frozen } }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "profiles": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "id",
             "name",
             "payloadKeys"
            ]
           }
          },
          "rollout": {
           "type": "object",
           "x-keys-not-described": [
            "target",
            "frozen"
           ]
          }
         },
         "x-keys-not-described": [
          "platform",
          "baseline",
          "effective",
          "profileEnforcementOn"
         ]
        }
       }
      }
     },
     "404": {
      "description": "for an unknown device"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/{id}/activity": {
   "get": {
    "operationId": "get_user_management_devices_id_activity",
    "tags": [
     "Device Management"
    ],
    "summary": "The device's audit history (its Activity tab). Query: ?limit (default 50, 1–200). Newest first.",
    "description": "Reply, as the catalogue words it: 200 DeviceActivityEntry[] (typed under Response Objects) { id, action, level, userEmail, createdAt, details }; no offset parameter\n\nAuth: Session + view_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Device primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "DeviceActivityEntry[] (typed under Response Objects) { id, action, level, userEmail, createdAt, details }; no offset parameter",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/DeviceActivityEntry"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-typed-objects": [
     "DeviceActivityEntry"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/trends": {
   "get": {
    "operationId": "get_user_management_devices_trends",
    "tags": [
     "Device Management"
    ],
    "summary": "Daily fleet snapshots for the trend chart. Query: ?days (default 30, at most 365).",
    "description": "Reply, as the catalogue words it: [{ day, metrics }], oldest first\n\nAuth: Session + view_devices",
    "responses": {
     "default": {
      "description": "[{ day, metrics }], oldest first",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/compliance/coverage": {
   "get": {
    "operationId": "get_user_management_devices_compliance_coverage",
    "tags": [
     "Device Management"
    ],
    "summary": "Which compliance requirements your profiles cover (Profiles → Compliance coverage). Query: ?framework = SOC2, ISO27001, HIPAA, PCI, NIST, HITRUST or all.",
    "description": "Reply, as the catalogue words it: 200 { framework, onlineDays, kpis, requirements } | 400 for an unknown framework\n\nAuth: Session + view_devices",
    "responses": {
     "200": {
      "description": "{ framework, onlineDays, kpis, requirements }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "for an unknown framework"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/settings/alerts": {
   "get": {
    "operationId": "get_user_management_devices_settings_alerts",
    "tags": [
     "Device Management"
    ],
    "summary": "Device alert settings.",
    "description": "Reply, as the catalogue words it: { pendingEnrollment, noncompliant, encryptionOff, silentDays, enrollTokenDays, pushCertDays[], recipients, configured }\n\nAuth: Session + view_devices",
    "responses": {
     "default": {
      "description": "{ pendingEnrollment, noncompliant, encryptionOff, silentDays, enrollTokenDays, pushCertDays[], recipients, configured }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-catalogue-group": "device-management"
   },
   "put": {
    "operationId": "put_user_management_devices_settings_alerts",
    "tags": [
     "Device Management"
    ],
    "summary": "Change device alert settings; only the fields sent are changed. Audited.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 200 the full settings | 400 with the reason for a value out of range\n\nGuard: only the keys sent are changed. Repeat: safe; the same values are saved again and the audit entry records no change\n\nAuth: Session + manage_devices",
    "requestBody": {
     "description": "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",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DeviceAlertSettingsUpdate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the full settings"
     },
     "400": {
      "description": "with the reason for a value out of range"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/alerts/recent": {
   "get": {
    "operationId": "get_user_management_devices_alerts_recent",
    "tags": [
     "Device Management"
    ],
    "summary": "Alerts that fired or cleared recently. Query: ?limit (default 50, 1–200). Newest first.",
    "description": "Reply, as the catalogue words it: 200 an array of alert events: the stored row (subjectType, subjectId, state, sentAt, createdAt and so on) plus a readable label\n\nAuth: Session + view_devices",
    "responses": {
     "200": {
      "description": "an array of alert events: the stored row (subjectType, subjectId, state, sentAt, createdAt and so on) plus a readable label"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/settings/stale": {
   "get": {
    "operationId": "get_user_management_devices_settings_stale",
    "tags": [
     "Device Management"
    ],
    "summary": "Days without a check-in before a device counts as stale.",
    "description": "Reply, as the catalogue words it: { staleDays, deviceStaleDays, default: 7, min: 1, max: 365 }\n\nAuth: Session + view_devices",
    "responses": {
     "default": {
      "description": "{ staleDays, deviceStaleDays, default: 7, min: 1, max: 365 }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-catalogue-group": "device-management"
   },
   "put": {
    "operationId": "put_user_management_devices_settings_stale",
    "tags": [
     "Device Management"
    ],
    "summary": "Set the stale threshold. Audited with the old and new value. Safe to repeat: saving the value it already has changes nothing.",
    "description": "Request, as the catalogue words it: A DeviceStaleSettingUpdate: { staleDays } — a whole number from 1 to 365\n\nReply, as the catalogue words it: 200 the same shape as the GET | 400 when out of range\n\nGuard: Repeat: safe; saving the value it already has changes nothing\n\nAuth: Session + manage_devices",
    "requestBody": {
     "description": "A DeviceStaleSettingUpdate: { staleDays } — a whole number from 1 to 365",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DeviceStaleSettingUpdate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the same shape as the GET"
     },
     "400": {
      "description": "when out of range"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/custom-fields": {
   "get": {
    "operationId": "get_user_management_devices_custom_fields",
    "tags": [
     "Device Management"
    ],
    "summary": "Your own device fields.",
    "description": "Reply, as the catalogue words it: { fields }\n\nGuard: the whole field list in one reply (no paging)\n\nAuth: Session + view_devices",
    "responses": {
     "default": {
      "description": "{ fields }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-catalogue-group": "device-management"
   },
   "post": {
    "operationId": "post_user_management_devices_custom_fields",
    "tags": [
     "Device Management"
    ],
    "summary": "Replace the set of custom device fields. Audited. A replace-style write, safe to repeat.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: { fields }\n\nGuard: replaces the whole list. Repeat: safe; the same list is stored again (each call is audited)\n\nAuth: Session + manage_devices",
    "requestBody": {
     "description": "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",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DeviceCustomFieldsWrite"
       }
      }
     }
    },
    "responses": {
     "default": {
      "description": "{ fields }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/install-invites": {
   "post": {
    "operationId": "post_user_management_devices_install_invites",
    "tags": [
     "Device Management"
    ],
    "summary": "Email the macOS install link to chosen people. The server builds the link itself.",
    "description": "Request, as the catalogue words it: An InstallInvitesSend: { userIds } — the users to email\n\nReply, as the catalogue words it: { sent, skipped, failed } | 400 when no user is selected, no agent package is hosted, or the download address cannot be built\n\nGuard: sends real email. Repeat: every user named is emailed again\n\nAuth: Session + manage_devices",
    "requestBody": {
     "description": "An InstallInvitesSend: { userIds } — the users to email",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/InstallInvitesSend"
       }
      }
     }
    },
    "responses": {
     "400": {
      "description": "when no user is selected, no agent package is hosted, or the download address cannot be built"
     },
     "default": {
      "description": "{ sent, skipped, failed }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/agent-pkg": {
   "post": {
    "operationId": "post_user_management_devices_agent_pkg",
    "tags": [
     "Device Management"
    ],
    "summary": "Upload an agent installer for the instance to host.",
    "description": "Request, as the catalogue words it: 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).\n\nReply, as the catalogue words it: 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\n\nGuard: 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.\n\nAuth: Session + manage_devices",
    "requestBody": {
     "description": "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).",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "an AgentPackageUpload (typed under Response Objects)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AgentPackageUpload"
        }
       }
      }
     },
     "400": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-typed-objects": [
     "AgentPackageUpload"
    ],
    "x-catalogue-group": "device-management"
   },
   "get": {
    "operationId": "get_user_management_devices_agent_pkg",
    "tags": [
     "Device Management"
    ],
    "summary": "Whether an installer is hosted for a platform, and which one is newest.",
    "description": "Request, as the catalogue words it: Query: ?platform= (macos | linux | windows; default macos).\n\nReply, as the catalogue words it: 200 { exists:false, platform } when none is hosted, else an AgentPackage (typed under Response Objects) with exists:true and platform\n\nAuth: Session + view_devices",
    "responses": {
     "200": {
      "description": "{ exists:false, platform } when none is hosted, else an AgentPackage (typed under Response Objects) with exists:true and platform",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-typed-objects": [
     "AgentPackage"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/agent-pkgs": {
   "get": {
    "operationId": "get_user_management_devices_agent_pkgs",
    "tags": [
     "Device Management"
    ],
    "summary": "Every hosted version of a platform's installer.",
    "description": "Request, as the catalogue words it: Query: ?platform= (macos | linux | windows; default macos).\n\nReply, as the catalogue words it: 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\n\nAuth: Session + view_devices",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "versions": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/AgentPackage"
           }
          }
         },
         "x-keys-not-described": [
          "platform"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-typed-objects": [
     "AgentPackage"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/agent-pkg/download": {
   "get": {
    "operationId": "get_user_management_devices_agent_pkg_download",
    "tags": [
     "Device Management"
    ],
    "summary": "Download the newest hosted installer of a platform.",
    "description": "Request, as the catalogue words it: Query: ?platform= (macos | linux | windows; default macos).\n\nReply, as the catalogue words it: 200 the file (application/octet-stream, Content-Disposition attachment, X-Zx-Pkg-Sha256 header) | 404 { error:'No <platform> agent is hosted' }\n\nAuth: Session + view_devices",
    "responses": {
     "200": {
      "description": "the file (application/octet-stream, Content-Disposition attachment, X-Zx-Pkg-Sha256 header)"
     },
     "404": {
      "description": "{ error:'No <platform> agent is hosted' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/setup-state": {
   "get": {
    "operationId": "get_user_management_devices_setup_state",
    "tags": [
     "Device Management"
    ],
    "summary": "Which platforms are in use on the instance: an installer hosted, or devices enrolled.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 a DeviceSetupState (typed under Response Objects)\n\nAuth: Session + view_devices",
    "responses": {
     "200": {
      "description": "a DeviceSetupState (typed under Response Objects)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/DeviceSetupState"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-typed-objects": [
     "DeviceSetupState"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/owner": {
   "get": {
    "operationId": "get_user_management_devices_owner",
    "tags": [
     "Device Management"
    ],
    "summary": "Data-subject export: every device owned by one person. Query: ?userId.",
    "description": "Reply, as the catalogue words it: 200 { devices: Device[] } — empty, never an error, for a missing or unknown userId\n\nGuard: every device of that person in one reply, id ascending (no paging)\n\nAuth: Session + manage_devices or manage_security",
    "responses": {
     "200": {
      "description": "{ devices: Device[] } — empty, never an error, for a missing or unknown userId",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "devices": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/Device"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices or manage_security",
    "x-permissions-named": [
     "manage_devices",
     "manage_security"
    ],
    "x-typed-objects": [
     "Device"
    ],
    "x-catalogue-group": "device-management"
   },
   "delete": {
    "operationId": "delete_user_management_devices_owner",
    "tags": [
     "Device Management"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: No body. Query: ?userId, the person whose devices are cleared\n\nReply, as the catalogue words it: 200 { erased } — the number of devices changed; { erased: 0 } for a missing or non-numeric userId\n\nGuard: Repeat: safe; a second call finds no device of that person, answers { erased: 0 }\n\nAuth: Session + manage_devices or manage_security",
    "responses": {
     "200": {
      "description": "{ erased } — the number of devices changed; { erased: 0 } for a missing or non-numeric userId",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices or manage_security",
    "x-permissions-named": [
     "manage_devices",
     "manage_security"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/reservations/suggestions": {
   "get": {
    "operationId": "get_user_management_devices_reservations_suggestions",
    "tags": [
     "Device Management"
    ],
    "summary": "New-hire computers: enrolled, unowned devices that may be a reserved computer, with the reservations they could match.",
    "description": "Reply, as the catalogue words it: { suggestions, open }\n\nAuth: Session + view_devices",
    "responses": {
     "default": {
      "description": "{ suggestions, open }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/{id}/link-reservation": {
   "post": {
    "operationId": "post_user_management_devices_id_link_reservation",
    "tags": [
     "Device Management"
    ],
    "summary": "Confirm that an enrolled device is a reserved new-hire computer; the new hire becomes its owner.",
    "description": "Request, as the catalogue words it: A ReservationLink: { reservationId }\n\nReply, as the catalogue words it: { id, linked, userId } | 400 no reservationId or device not enrolled | 404 unknown device | 409 reservation no longer open\n\nGuard: Repeat: 409; the reservation record is removed once it is linked, so it is no longer open\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Device primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A ReservationLink: { reservationId }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/ReservationLink"
       }
      }
     }
    },
    "responses": {
     "400": {
      "description": "no reservationId or device not enrolled"
     },
     "404": {
      "description": "unknown device"
     },
     "409": {
      "description": "reservation no longer open"
     },
     "default": {
      "description": "{ id, linked, userId }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/{id}/dismiss-reservation-suggestion": {
   "post": {
    "operationId": "post_user_management_devices_id_dismiss_reservation_suggestion",
    "tags": [
     "Device Management"
    ],
    "summary": "Mark a device as not a new hire's, so it stops being suggested. Audited.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: { id, dismissed } | 404 unknown device\n\nGuard: Repeat: safe; the device stays dismissed\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Device primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "404": {
      "description": "unknown device"
     },
     "default": {
      "description": "{ id, dismissed }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/enroll-tokens": {
   "get": {
    "operationId": "get_user_management_devices_enroll_tokens",
    "tags": [
     "Device Management"
    ],
    "summary": "List enrolment tokens: the newest 200, with status and use count, never the token value. No query parameters.",
    "description": "Reply, as the catalogue words it: 200 EnrollmentToken[] (typed under Response Objects) { id, label, expiresAt, maxUses, useCount, usedAt, revokedAt, createdAt, status }: the 200 newest, newest first, with no paging parameter\n\nAuth: Session + view_devices",
    "responses": {
     "200": {
      "description": "EnrollmentToken[] (typed under Response Objects) { id, label, expiresAt, maxUses, useCount, usedAt, revokedAt, createdAt, status }: the 200 newest, newest first, with no paging parameter",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/EnrollmentToken"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-typed-objects": [
     "EnrollmentToken"
    ],
    "x-catalogue-group": "device-management"
   },
   "post": {
    "operationId": "post_user_management_devices_enroll_tokens",
    "tags": [
     "Device Management"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: An EnrollmentTokenCreate: { label (cut to 120 characters), ttlHours (default 24, clamped 1–720), maxUses (default 1, clamped 1–500) }\n\nReply, as the catalogue words it: 201 an EnrollmentTokenIssued { id, token, label, expiresAt, maxUses } (typed under Response Objects); token is shown only here\n\nGuard: Repeat: issues another token; two calls are never treated as one\n\nAuth: Session + manage_devices",
    "requestBody": {
     "description": "An EnrollmentTokenCreate: { label (cut to 120 characters), ttlHours (default 24, clamped 1–720), maxUses (default 1, clamped 1–500) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/EnrollmentTokenCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "an EnrollmentTokenIssued { id, token, label, expiresAt, maxUses } (typed under Response Objects); token is shown only here",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/EnrollmentTokenIssued"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-typed-objects": [
     "EnrollmentTokenIssued"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/enroll-tokens/{id}/revoke": {
   "post": {
    "operationId": "post_user_management_devices_enroll_tokens_id_revoke",
    "tags": [
     "Device Management"
    ],
    "summary": "Revoke an enrolment token. No body. Safe to repeat: a second call returns revoked: false.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { revoked } — true only when this call revoked it\n\nGuard: Repeat: safe; a second call answers { revoked: false }\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceEnrollmentToken primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ revoked } — true only when this call revoked it",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/{id}/approve-enrollment": {
   "post": {
    "operationId": "post_user_management_devices_id_approve_enrollment",
    "tags": [
     "Device Management"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { id, enrollmentApproved: true } | 404 | 409 when the device is retired\n\nGuard: Repeat: safe; an approved device stays approved (each call is audited)\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Device primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ id, enrollmentApproved: true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "enrollmentApproved": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "id"
         ]
        }
       }
      }
     },
     "404": {
      "description": "No further text in the catalogue."
     },
     "409": {
      "description": "when the device is retired"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/{id}/reject-enrollment": {
   "post": {
    "operationId": "post_user_management_devices_id_reject_enrollment",
    "tags": [
     "Device Management"
    ],
    "summary": "Reject a device that is waiting for enrolment approval.",
    "description": "Request, as the catalogue words it: An EnrollmentReject: { reason } — optional, at most 234 characters\n\nReply, as the catalogue words it: 200 { id, rejected: true, outcome } | 400 reason too long | 404 'Device not found' | 409 unless the enrolment is still pending\n\nGuard: Repeat: 409; after the first call the enrolment is no longer pending\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Device primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "An EnrollmentReject: { reason } — optional, at most 234 characters",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/EnrollmentReject"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ id, rejected: true, outcome }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "rejected": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "id",
          "outcome"
         ]
        }
       }
      }
     },
     "400": {
      "description": "reason too long"
     },
     "404": {
      "description": "'Device not found'"
     },
     "409": {
      "description": "unless the enrolment is still pending"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/batch/approve": {
   "post": {
    "operationId": "post_user_management_devices_batch_approve",
    "tags": [
     "Device Management"
    ],
    "summary": "Approve several waiting devices in one call.",
    "description": "Request, as the catalogue words it: A DeviceIdsRequest: { ids } — 1 to 500 device ids\n\nReply, as the catalogue words it: 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'\n\nGuard: 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\n\nAuth: Session + manage_devices",
    "requestBody": {
     "description": "A DeviceIdsRequest: { ids } — 1 to 500 device ids",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DeviceIdsRequest"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "a DeviceBatchOutcome { ok: [{ deviceId, enrollmentApproved: true }], failed: [{ deviceId, error }] }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/DeviceBatchOutcome"
        }
       }
      }
     },
     "400": {
      "description": "'ids must be a non-empty list of device ids' or 'At most 500 devices per request'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-typed-objects": [
     "DeviceBatchOutcome"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/{id}/allow-reenroll": {
   "post": {
    "operationId": "post_user_management_devices_id_allow_reenroll",
    "tags": [
     "Device Management"
    ],
    "summary": "Let an already-enrolled device enrol again with a new signing key. No body. Audited as DEVICE_REENROLL_ALLOWED.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { id, reEnrollAllowed: true } | 404\n\nGuard: Repeat: safe; the approval stays set until the device next enrols, which uses it up (each call is audited)\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Device primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ id, reEnrollAllowed: true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "reEnrollAllowed": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "id"
         ]
        }
       }
      }
     },
     "404": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/policy": {
   "get": {
    "operationId": "get_user_management_devices_policy",
    "tags": [
     "Device Management"
    ],
    "summary": "Read the fleet device policy (the write is PUT on the same path, the next row).",
    "description": "Reply, as the catalogue words it: 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\n\nGuard: one object, not a list\n\nAuth: Session + view_devices",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-catalogue-group": "device-management"
   },
   "put": {
    "operationId": "put_user_management_devices_policy",
    "tags": [
     "Device Management"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: 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\" }\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_devices",
    "requestBody": {
     "description": "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\" }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DevicePolicyUpdate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the saved policy of that platform"
     },
     "400": {
      "description": "{ error } for a minOsVersion or minAgentVersion that is not a version, or a minAgentVersion above the adopted version",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "409": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/{id}/assign": {
   "post": {
    "operationId": "post_user_management_devices_id_assign",
    "tags": [
     "Device Management"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: 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 }\n\nReply, as the catalogue words it: 200 { id, userId, assetTag, department, location, costCenter } | 404\n\nGuard: Repeat: safe; a replace-style write (each call is audited as DEVICE_ASSIGNED)\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Device primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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 }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DeviceAssign"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ id, userId, assetTag, department, location, costCenter }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/batch/assign": {
   "post": {
    "operationId": "post_user_management_devices_batch_assign",
    "tags": [
     "Device Management"
    ],
    "summary": "Assign several devices to one person, or unassign them, in one call.",
    "description": "Request, as the catalogue words it: A DeviceBatchAssign: { ids — 1 to 500 device ids, userId (required: a user id, or null to unassign) }\n\nReply, as the catalogue words it: 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\n\nGuard: each device is tried on its own, so read failed[] even on a 200; a replace-style write, safe to repeat\n\nAuth: Session + manage_devices",
    "requestBody": {
     "description": "A DeviceBatchAssign: { ids — 1 to 500 device ids, userId (required: a user id, or null to unassign) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DeviceBatchAssign"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "a DeviceBatchOutcome { ok: [{ deviceId, userId }], failed: [{ deviceId, error }] }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/DeviceBatchOutcome"
        }
       }
      }
     },
     "400": {
      "description": "'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"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-typed-objects": [
     "DeviceBatchOutcome"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/commands/catalog": {
   "get": {
    "operationId": "get_user_management_devices_commands_catalog",
    "tags": [
     "Device Management"
    ],
    "summary": "The commands the agent can run: lock, uninstall, restart, update_os and rename.",
    "description": "Reply, as the catalogue words it: 200 { types: DeviceCommandType[] } (typed under Response Objects), each { type, label, platforms, destructive, args }\n\nGuard: a fixed list, not paged\n\nAuth: Session + view_devices",
    "responses": {
     "200": {
      "description": "{ types: DeviceCommandType[] } (typed under Response Objects), each { type, label, platforms, destructive, args }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "types": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/DeviceCommandType"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-typed-objects": [
     "DeviceCommandType"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/{id}/commands": {
   "get": {
    "operationId": "get_user_management_devices_id_commands",
    "tags": [
     "Device Management"
    ],
    "summary": "A device's command queue and history. Query: ?limit (default 50, 1–200). Newest first.",
    "description": "Reply, as the catalogue words it: 200 DeviceCommand[] (typed under Response Objects) { id, deviceId, type, args, status, requestedBy, reason, batchId, result, deliveredAt, completedAt, createdAt, updatedAt } | 404 unknown device\n\nAuth: Session + view_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Device primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "DeviceCommand[] (typed under Response Objects) { id, deviceId, type, args, status, requestedBy, reason, batchId, result, deliveredAt, completedAt, createdAt, updatedAt }",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/DeviceCommand"
         }
        }
       }
      }
     },
     "404": {
      "description": "unknown device"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-typed-objects": [
     "DeviceCommand"
    ],
    "x-catalogue-group": "device-management"
   },
   "post": {
    "operationId": "post_user_management_devices_id_commands",
    "tags": [
     "Device Management"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Device primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DeviceCommandCreate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "a DeviceCommandResult (typed under Response Objects) { ok: [{ deviceId, commandId, noop }], failed: [] } — noop is present, as true, only when nothing new was queued",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/DeviceCommandResult"
        }
       }
      }
     },
     "400": {
      "description": "{ error } unknown command or invalid arguments | { 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 }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "No further text in the catalogue. | "
     },
     "409": {
      "description": "with failed[].code no_agent, a retired device, an uninstall already queued, or the same command queued with different arguments | unless still queued"
     },
     "422": {
      "description": "with failed[].code unsupported for this platform — a 404, 409 or 422 answers { ok: [], failed, error, pendingCommand }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-typed-objects": [
     "DeviceCommandResult"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/batch/commands": {
   "post": {
    "operationId": "post_user_management_devices_batch_commands",
    "tags": [
     "Device Management"
    ],
    "summary": "Queue one command for several devices.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 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)\n\nGuard: 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\n\nAuth: Session + manage_devices (uninstall also needs manage_device_destructive and a fresh step-up)",
    "requestBody": {
     "description": "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",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DeviceBatchCommandCreate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "a DeviceCommandResult (typed under Response Objects) { ok: [{ deviceId, commandId, noop }], failed: [{ deviceId, error, pendingCommand? }], batchId }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/DeviceCommandResult"
        }
       }
      }
     },
     "400": {
      "description": "{ error } for an unknown command, invalid arguments, no ids or more than 500 | { error: 'SUDO_REQUIRED' } for uninstall without a fresh step-up (the service answers 401; the gateway returns it as 400)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices (uninstall also needs manage_device_destructive and a fresh step-up)",
    "x-permissions-named": [
     "manage_device_destructive",
     "manage_devices"
    ],
    "x-step-up": "conditional",
    "x-typed-objects": [
     "DeviceCommandResult"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/{id}/commands/{commandId}": {
   "delete": {
    "operationId": "delete_user_management_devices_id_commands_commandId",
    "tags": [
     "Device Management"
    ],
    "summary": "Cancel a command that is still queued.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: 409; only a command that is still queued can be cancelled\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Device primary key.",
      "x-type-basis": "model"
     },
     {
      "name": "commandId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceCommand primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ id, deviceId, status: 'cancelled', pendingCommand }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "'Device not found' or 'Command not found'"
     },
     "409": {
      "description": "when the command is no longer queued, or was delivered before it could be cancelled"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/retire": {
   "post": {
    "operationId": "post_user_management_devices_retire",
    "tags": [
     "Device Management"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: A DeviceRetire: { ids — 1 to 500 device ids, reason (required, 3–255 characters) }. Unretire: { ids }\n\nReply, as the catalogue words it: 200 a DeviceLifecycleOutcome { ok: [ids], failed: [{ id, error }] } | 400 no ids, more than 500, or a missing or over-long reason\n\nGuard: 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\n\nAuth: Session + manage_devices",
    "requestBody": {
     "description": "A DeviceRetire: { ids — 1 to 500 device ids, reason (required, 3–255 characters) }. Unretire: { ids }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DeviceRetire"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "a DeviceLifecycleOutcome { ok: [ids], failed: [{ id, error }] }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/DeviceLifecycleOutcome"
        }
       }
      }
     },
     "400": {
      "description": "no ids, more than 500, or a missing or over-long reason"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-typed-objects": [
     "DeviceLifecycleOutcome"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/unretire": {
   "post": {
    "operationId": "post_user_management_devices_unretire",
    "tags": [
     "Device Management"
    ],
    "summary": "Bring retired devices back into service.",
    "description": "Request, as the catalogue words it: A DeviceIdsRequest: { ids } — 1 to 500 device ids\n\nReply, as the catalogue words it: 200 a DeviceLifecycleOutcome { ok: [ids], failed: [{ id, error }] } | 400 no ids or more than 500\n\nGuard: safe to repeat: a device that is not retired is reported in ok and nothing is changed\n\nAuth: Session + manage_devices",
    "requestBody": {
     "description": "A DeviceIdsRequest: { ids } — 1 to 500 device ids",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DeviceIdsRequest"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "a DeviceLifecycleOutcome { ok: [ids], failed: [{ id, error }] }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/DeviceLifecycleOutcome"
        }
       }
      }
     },
     "400": {
      "description": "no ids or more than 500"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-typed-objects": [
     "DeviceLifecycleOutcome"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/groups": {
   "get": {
    "operationId": "get_user_management_devices_groups",
    "tags": [
     "Device Management"
    ],
    "summary": "Device groups (members at /devices/groups/:id/devices). No query parameters; sorted by name, not paged.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Session + view_devices",
    "responses": {
     "200": {
      "description": "{ 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 }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "groups": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/DeviceGroup"
           }
          }
         },
         "x-keys-not-described": [
          "fields",
          "ops",
          "fieldOps",
          "complianceValues"
         ]
        }
       }
      }
     },
     "404": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-typed-objects": [
     "DeviceGroup"
    ],
    "x-catalogue-group": "device-management"
   },
   "post": {
    "operationId": "post_user_management_devices_groups",
    "tags": [
     "Device Management"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: 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 }\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: creates a second group; group names are not required to be unique\n\nAuth: Session + manage_devices",
    "requestBody": {
     "description": "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 }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DeviceGroupCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ id }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "no name or invalid criteria. PUT: 200 { ok: true, memberCount, enforceableCount } | for a smart group, no deviceIds or a device of another platform",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": ". DELETE (?force=1 to strip it from the scopes that use it): 200 { ok: true, strippedFrom } |  | unknown group",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "409": {
      "description": "with usedBy when a profile or agent rollout still uses it. Members: 200 { added }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/groups/preview": {
   "post": {
    "operationId": "post_user_management_devices_groups_preview",
    "tags": [
     "Device Management"
    ],
    "summary": "Count the devices a smart group's criteria would match, without saving anything.",
    "description": "Request, as the catalogue words it: A DeviceGroupPreview: { criteria } (kind, when sent, must be 'smart')\n\nReply, as the catalogue words it: 200 { count, sample, memberCount } | 400 { error } for a static group or invalid criteria\n\nGuard: Repeat: safe; nothing is stored\n\nAuth: Session + view_devices",
    "requestBody": {
     "description": "A DeviceGroupPreview: { criteria } (kind, when sent, must be 'smart')",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DeviceGroupPreview"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ count, sample, memberCount }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "{ error } for a static group or invalid criteria",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/groups/{id}": {
   "put": {
    "operationId": "put_user_management_devices_groups_id",
    "tags": [
     "Device Management"
    ],
    "summary": "Rename a device group, change its description, or change a smart group's criteria. Audited as DEVICE_GROUP_UPDATED.",
    "description": "Request, as the catalogue words it: A DeviceGroupUpdate: { name, description, criteria (smart groups only; ignored on a static group) } — all optional\n\nReply, as the catalogue words it: 200 { ok: true, memberCount, enforceableCount } | 400 invalid criteria | 404 'group not found'\n\nGuard: Repeat: safe; a replace-style write (each call is audited)\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceGroup primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A DeviceGroupUpdate: { name, description, criteria (smart groups only; ignored on a static group) } — all optional",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DeviceGroupUpdate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok: true, memberCount, enforceableCount }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "memberCount",
          "enforceableCount"
         ]
        }
       }
      }
     },
     "400": {
      "description": "invalid criteria"
     },
     "404": {
      "description": "'group not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   },
   "delete": {
    "operationId": "delete_user_management_devices_groups_id",
    "tags": [
     "Device Management"
    ],
    "summary": "Delete a device group. Audited as DEVICE_GROUP_DELETED.",
    "description": "Request, as the catalogue words it: Query: ?force=1 (or true) also removes the group from every profile and agent-rollout scope that uses it\n\nReply, as the catalogue words it: 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\n\nGuard: check GET /devices/groups/:id/usage first; a repeat answers 404\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceGroup primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ ok: true, strippedFrom: { profiles, rollouts } }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "strippedFrom": {
           "type": "object",
           "x-keys-not-described": [
            "profiles",
            "rollouts"
           ]
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "'group not found'"
     },
     "409": {
      "description": "{ error, usedBy } when a profile or agent rollout still uses it and force is not set",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/groups/{id}/usage": {
   "get": {
    "operationId": "get_user_management_devices_groups_id_usage",
    "tags": [
     "Device Management"
    ],
    "summary": "The profiles and agent rollouts whose scope uses this group.",
    "description": "Reply, as the catalogue words it: 200 { groupId, usedBy: { profiles, rollouts }, count } | 404 'group not found'\n\nAuth: Session + view_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceGroup primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ groupId, usedBy: { profiles, rollouts }, count }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "usedBy": {
           "type": "object",
           "x-keys-not-described": [
            "profiles",
            "rollouts"
           ]
          }
         },
         "x-keys-not-described": [
          "groupId",
          "count"
         ]
        }
       }
      }
     },
     "404": {
      "description": "'group not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/groups/{id}/devices": {
   "get": {
    "operationId": "get_user_management_devices_groups_id_devices",
    "tags": [
     "Device Management"
    ],
    "summary": "The devices in a group.",
    "description": "Reply, as the catalogue words it: 200 { group: { id, name, kind, platform, criteria, description }, devices, memberCount, enforceableCount } | 404 'group not found'\n\nGuard: every member in one reply (no paging)\n\nAuth: Session + view_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceGroup primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ group: { id, name, kind, platform, criteria, description }, devices, memberCount, enforceableCount }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "group": {
           "type": "object",
           "x-keys-not-described": [
            "id",
            "name",
            "kind",
            "platform",
            "criteria",
            "description"
           ]
          }
         },
         "x-keys-not-described": [
          "devices",
          "memberCount",
          "enforceableCount"
         ]
        }
       }
      }
     },
     "404": {
      "description": "'group not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/groups/{id}/members": {
   "post": {
    "operationId": "post_user_management_devices_groups_id_members",
    "tags": [
     "Device Management"
    ],
    "summary": "Add devices to a static group. Audited as DEVICE_GROUP_MEMBERS_ADDED.",
    "description": "Request, as the catalogue words it: A DeviceGroupMembers: { deviceIds } — a non-empty array of device ids\n\nReply, as the catalogue words it: 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'\n\nGuard: safe to repeat: a device already in the group is not added twice\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceGroup primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A DeviceGroupMembers: { deviceIds } — a non-empty array of device ids",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DeviceGroupMembers"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ added } — the number newly added",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "for a smart group, no deviceIds, or a device of another platform in a platform-bound group"
     },
     "404": {
      "description": "'group not found'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/groups/{id}/members/{deviceId}": {
   "delete": {
    "operationId": "delete_user_management_devices_groups_id_members_deviceId",
    "tags": [
     "Device Management"
    ],
    "summary": "Remove one device from a group.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { removed } — 1, or 0 when the device was not a member\n\nGuard: safe to repeat\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceGroup primary key.",
      "x-type-basis": "model"
     },
     {
      "name": "deviceId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Device primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ removed } — 1, or 0 when the device was not a member",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/groups/{id}/members/bulk-remove": {
   "post": {
    "operationId": "post_user_management_devices_groups_id_members_bulk_remove",
    "tags": [
     "Device Management"
    ],
    "summary": "Remove several devices from a group.",
    "description": "Request, as the catalogue words it: A DeviceGroupMembers: { deviceIds } — a non-empty array of device ids\n\nReply, as the catalogue words it: 200 { removed } — the number removed | 400 'deviceIds[] required'\n\nGuard: safe to repeat\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceGroup primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A DeviceGroupMembers: { deviceIds } — a non-empty array of device ids",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DeviceGroupMembers"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ removed } — the number removed",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "'deviceIds[] required'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/profiles": {
   "get": {
    "operationId": "get_user_management_devices_profiles",
    "tags": [
     "Device Management"
    ],
    "summary": "Configuration profiles (catalogue at /devices/profiles/catalog, per-device status at /devices/:id/profile-status). No query parameters; sorted by name, not paged.",
    "description": "Reply, as the catalogue words it: 200 { profiles: [{ id, name, description, platform, mixed, payloads, scope, scopeLabel, scopeCount, scopeTotal, statusTracked, enabled, version, coversRequirements, createdBy, ... }] }\n\nAuth: Session + view_devices",
    "responses": {
     "200": {
      "description": "{ profiles: [{ id, name, description, platform, mixed, payloads, scope, scopeLabel, scopeCount, scopeTotal, statusTracked, enabled, version, coversRequirements, createdBy, ... }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "profiles": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "id",
             "name",
             "description",
             "platform",
             "mixed",
             "payloads",
             "scope",
             "scopeLabel",
             "scopeCount",
             "scopeTotal",
             "statusTracked",
             "enabled",
             "version",
             "coversRequirements",
             "createdBy"
            ]
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-catalogue-group": "device-management"
   },
   "post": {
    "operationId": "post_user_management_devices_profiles",
    "tags": [
     "Device Management"
    ],
    "summary": "Create a configuration profile (PUT or DELETE /devices/profiles/:id). Repeating the POST creates another profile: names are not unique.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: creates a second profile; profile names are not required to be unique\n\nAuth: Session + manage_devices",
    "requestBody": {
     "description": "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",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DeviceProfileCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ id }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "no name, no or unknown platform, or a payload the platform does not support. PUT: 200 { ok: true, version } | ",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "No further text in the catalogue. | "
     },
     "409": {
      "description": "while the profile is rolling out in stages. DELETE: 200 { ok: true, mdmRemoval, warning }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/profiles/{id}": {
   "put": {
    "operationId": "put_user_management_devices_profiles_id",
    "tags": [
     "Device Management"
    ],
    "summary": "Change a configuration profile.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: safe; a call that changes nothing keeps the version number (each call is audited)\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceProfile primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DeviceProfileUpdate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok: true, version }, plus rollout and mdm when rollout was sent",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "version"
         ]
        }
       }
      }
     },
     "400": {
      "description": ", including a rollout asked for when nothing changed on a profile that is already on"
     },
     "404": {
      "description": "No further text in the catalogue."
     },
     "409": {
      "description": "while the profile is rolling out in stages"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   },
   "delete": {
    "operationId": "delete_user_management_devices_profiles_id",
    "tags": [
     "Device Management"
    ],
    "summary": "Delete a configuration profile.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { ok: true, mdmRemoval, warning } | 404\n\nGuard: also ends a staged rollout of the profile that is still in progress. Repeat: 404\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceProfile primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ ok: true, mdmRemoval, warning }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "mdmRemoval",
          "warning"
         ]
        }
       }
      }
     },
     "404": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/profiles/{id}/deploy": {
   "post": {
    "operationId": "post_user_management_devices_profiles_id_deploy",
    "tags": [
     "Device Management"
    ],
    "summary": "Deploy a profile (several at /devices/profiles/bulk-deploy). No body.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceProfile primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ deployed, devicesTargeted, inScope, agentPending, enforcementOn, notPushable, heldByRollout, note }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "when the profile is disabled, or it has only MDM payloads and MDM is not connected"
     },
     "404": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/profiles/bulk-deploy": {
   "post": {
    "operationId": "post_user_management_devices_profiles_bulk_deploy",
    "tags": [
     "Device Management"
    ],
    "summary": "Deploy several profiles in one call.",
    "description": "Request, as the catalogue words it: A ProfileIdsRequest: { ids } — 1 to 100 profile ids; a repeated id is deployed once\n\nReply, as the catalogue words it: 200 { succeeded: [{ id, note }], failed: [{ id, error }] } | 400 'Select at least one profile.' / 'Too many profiles in one action (max 100).'\n\nGuard: each profile is tried on its own, so read failed[] even on a 200\n\nAuth: Session + manage_devices",
    "requestBody": {
     "description": "A ProfileIdsRequest: { ids } — 1 to 100 profile ids; a repeated id is deployed once",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/ProfileIdsRequest"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ succeeded: [{ id, note }], failed: [{ id, error }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "succeeded": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "id",
             "note"
            ]
           }
          },
          "failed": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "id",
             "error"
            ]
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "'Select at least one profile.' / 'Too many profiles in one action (max 100).'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/profiles/{id}/rollout": {
   "post": {
    "operationId": "post_user_management_devices_profiles_id_rollout",
    "tags": [
     "Device Management"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: 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) }\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: 409; starting the rollout turns the profile on, and a profile that is on cannot be started this way again\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceProfile primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/ProfileRolloutStart"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ rollout: ProfileRollout, mdm }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "rollout": {
           "$ref": "#/components/schemas/ProfileRollout"
          }
         },
         "x-keys-not-described": [
          "mdm"
         ]
        }
       }
      }
     },
     "400": {
      "description": "invalid rings or hours"
     },
     "404": {
      "description": "unknown profile"
     },
     "409": {
      "description": "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 | the rollout has ended",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-typed-objects": [
     "ProfileRollout"
    ],
    "x-catalogue-group": "device-management"
   },
   "get": {
    "operationId": "get_user_management_devices_profiles_id_rollout",
    "tags": [
     "Device Management"
    ],
    "summary": "The current (or most recent) phased rollout of a profile, with per-ring device counts.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Session + view_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceProfile primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "rollout": {
           "$ref": "#/components/schemas/ProfileRollout"
          }
         },
         "x-keys-not-described": [
          "rings",
          "statusTracked",
          "profile"
         ]
        }
       }
      }
     },
     "400": {
      "description": "'invalid profile id'"
     },
     "404": {
      "description": "unknown profile"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-typed-objects": [
     "ProfileRollout"
    ],
    "x-catalogue-group": "device-management"
   },
   "put": {
    "operationId": "put_user_management_devices_profiles_id_rollout",
    "tags": [
     "Device Management"
    ],
    "summary": "Change a running rollout's automatic promotion. Audited as DEVICE_PROFILE_ROLLOUT_UPDATED.",
    "description": "Request, as the catalogue words it: A ProfileRolloutSettings: { autoPromote (boolean), promoteAfterHours (whole number, 1–720) } — either or both\n\nReply, as the catalogue words it: 200 { rollout: ProfileRollout } | 400 'Promote after 1–720 hours.' | 404 unknown profile, or no rollout in progress\n\nGuard: Repeat: safe; a replace-style write (each call is audited)\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceProfile primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A ProfileRolloutSettings: { autoPromote (boolean), promoteAfterHours (whole number, 1–720) } — either or both",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/ProfileRolloutSettings"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ rollout: ProfileRollout }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "rollout": {
           "$ref": "#/components/schemas/ProfileRollout"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "'Promote after 1–720 hours.'"
     },
     "404": {
      "description": "unknown profile, or no rollout in progress"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-typed-objects": [
     "ProfileRollout"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/profiles/rollouts": {
   "get": {
    "operationId": "get_user_management_devices_profiles_rollouts",
    "tags": [
     "Device Management"
    ],
    "summary": "The latest rollout of every profile, keyed by profile id.",
    "description": "Reply, as the catalogue words it: 200 { rollouts: { [profileId]: { id, state, currentRing, rings, toVersion, holdReason, label, … } } }\n\nGuard: one object, not paged; built from the 500 most recent rollout records\n\nAuth: Session + view_devices",
    "responses": {
     "200": {
      "description": "{ rollouts: { [profileId]: { id, state, currentRing, rings, toVersion, holdReason, label, … } } }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "rollouts": {
           "type": "object",
           "additionalProperties": {
            "type": "object",
            "x-keys-not-described": [
             "id",
             "state",
             "currentRing",
             "rings",
             "toVersion",
             "holdReason",
             "label"
            ]
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/profiles/{id}/rollout/promote": {
   "post": {
    "operationId": "post_user_management_devices_profiles_id_rollout_promote",
    "tags": [
     "Device Management"
    ],
    "summary": "Move a rollout on to its next ring now.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { rollout: ProfileRollout, mdm } | 404 unknown profile, or no rollout in progress | 409 the rollout has ended\n\nGuard: 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\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceProfile primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ rollout: ProfileRollout, mdm }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "rollout": {
           "$ref": "#/components/schemas/ProfileRollout"
          }
         },
         "x-keys-not-described": [
          "mdm"
         ]
        }
       }
      }
     },
     "404": {
      "description": "unknown profile, or no rollout in progress"
     },
     "409": {
      "description": "the rollout has ended"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-typed-objects": [
     "ProfileRollout"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/profiles/{id}/rollout/pause": {
   "post": {
    "operationId": "post_user_management_devices_profiles_id_rollout_pause",
    "tags": [
     "Device Management"
    ],
    "summary": "Pause a rollout.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { rollout: ProfileRollout } | 404 unknown profile, or no rollout in progress | 409 the rollout has ended\n\nGuard: Repeat: safe; a paused rollout stays paused (each call is audited)\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceProfile primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ rollout: ProfileRollout }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "rollout": {
           "$ref": "#/components/schemas/ProfileRollout"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "unknown profile, or no rollout in progress"
     },
     "409": {
      "description": "the rollout has ended"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-typed-objects": [
     "ProfileRollout"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/profiles/{id}/rollout/resume": {
   "post": {
    "operationId": "post_user_management_devices_profiles_id_rollout_resume",
    "tags": [
     "Device Management"
    ],
    "summary": "Resume a paused rollout.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { rollout: ProfileRollout } | 404 unknown profile, or no rollout in progress | 409 the rollout has ended\n\nGuard: Repeat: the rollout stays running, but each call restarts the wait before automatic promotion\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceProfile primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ rollout: ProfileRollout }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "rollout": {
           "$ref": "#/components/schemas/ProfileRollout"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "unknown profile, or no rollout in progress"
     },
     "409": {
      "description": "the rollout has ended"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-typed-objects": [
     "ProfileRollout"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/profiles/{id}/rollout/rollback": {
   "post": {
    "operationId": "post_user_management_devices_profiles_id_rollout_rollback",
    "tags": [
     "Device Management"
    ],
    "summary": "Roll a rollout back to the version the profile had before it.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { rollout: ProfileRollout, version, mdm } | 404 unknown profile, or no rollout in progress | 409 the rollout has ended\n\nGuard: Repeat: 404; the rollout has ended, so none is in progress\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: DeviceProfile primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ rollout: ProfileRollout, version, mdm }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "rollout": {
           "$ref": "#/components/schemas/ProfileRollout"
          }
         },
         "x-keys-not-described": [
          "version",
          "mdm"
         ]
        }
       }
      }
     },
     "404": {
      "description": "unknown profile, or no rollout in progress"
     },
     "409": {
      "description": "the rollout has ended"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-typed-objects": [
     "ProfileRollout"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/mdm/status": {
   "get": {
    "operationId": "get_user_management_devices_mdm_status",
    "tags": [
     "Device Management"
    ],
    "summary": "Apple MDM connection status (preview). Enrolled Macs at /devices/mdm/devices.",
    "description": "Reply, as the catalogue words it: 200 an MdmStatus (typed under Response Objects): { configured: false }, or { configured: true, connected:boolean, tenant, deviceCount, enrollmentUrl, enrollmentReady, hasCert, certNotAfter }\n\nAuth: Session + view_devices",
    "responses": {
     "200": {
      "description": "an MdmStatus (typed under Response Objects): { configured: false }, or { configured: true, connected:boolean, tenant, deviceCount, enrollmentUrl, enrollmentReady, hasCert, certNotAfter }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/MdmStatus"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-typed-objects": [
     "MdmStatus"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/mdm/enroll-code": {
   "post": {
    "operationId": "post_user_management_devices_mdm_enroll_code",
    "tags": [
     "Device Management"
    ],
    "summary": "Create an MDM enrolment code for a Mac. Audited as DEVICE_MDM_ENROLL_CODE.",
    "description": "Request, as the catalogue words it: An MdmEnrollCodeCreate: { reusable } — true for a reusable code; otherwise single-use\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: asks the MDM control plane for another code\n\nAuth: Session + manage_devices",
    "requestBody": {
     "description": "An MdmEnrollCodeCreate: { reusable } — true for a reusable code; otherwise single-use",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/MdmEnrollCodeCreate"
       }
      }
     }
    },
    "responses": {
     "400": {
      "description": "when Apple MDM is not configured"
     },
     "424": {
      "description": "when the MDM control plane did not answer"
     },
     "default": {
      "description": "the MDM control plane's own status and body"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/mdm/pushcert": {
   "post": {
    "operationId": "post_user_management_devices_mdm_pushcert",
    "tags": [
     "Device Management"
    ],
    "summary": "Upload your Apple push (APNs) certificate (start from GET /devices/mdm/pushcert/request). Audited as DEVICE_MDM_PUSHCERT_UPLOAD.",
    "description": "Request, as the catalogue words it: An MdmPushCertUpload: { certPem, keyPem } or { p12, passphrase }\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: sends the certificate to the MDM control plane again; after a 424 reload the status first\n\nAuth: Session + manage_devices",
    "requestBody": {
     "description": "An MdmPushCertUpload: { certPem, keyPem } or { p12, passphrase }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/MdmPushCertUpload"
       }
      }
     }
    },
    "responses": {
     "400": {
      "description": "when the certificate is missing or the .p12 cannot be opened | when Apple MDM is not configured"
     },
     "413": {
      "description": "over 200,000 characters"
     },
     "424": {
      "description": "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"
     },
     "default": {
      "description": "the MDM control plane's own status and body"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/mdm/{udid}/lock": {
   "post": {
    "operationId": "post_user_management_devices_mdm_udid_lock",
    "tags": [
     "Device Management"
    ],
    "summary": "Remote-lock a Mac through MDM. Audited as DEVICE_MDM_LOCK.",
    "description": "Request, as the catalogue words it: An MdmLockRequest: { message } — optional\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "udid",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "requestBody": {
     "description": "An MdmLockRequest: { message } — optional",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/MdmLockRequest"
       }
      }
     }
    },
    "responses": {
     "400": {
      "description": "when Apple MDM is not configured"
     },
     "424": {
      "description": "when the MDM control plane did not answer, or answered but could not queue the lock"
     },
     "default": {
      "description": "the MDM control plane's own status and body"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/mdm/{udid}/wipe": {
   "post": {
    "operationId": "post_user_management_devices_mdm_udid_wipe",
    "tags": [
     "Device Management"
    ],
    "summary": "Remote-wipe a Mac through MDM. Audited as DEVICE_MDM_WIPE at critical level, including when the call fails.",
    "description": "Request, as the catalogue words it: A DestructiveReason: { reason } — required, at least 3 characters, cut to 500\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_device_destructive + sudo",
    "parameters": [
     {
      "name": "udid",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "requestBody": {
     "description": "A DestructiveReason: { reason } — required, at least 3 characters, cut to 500",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DestructiveReason"
       }
      }
     }
    },
    "responses": {
     "400": {
      "description": "no reason | when Apple MDM is not configured | { 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "424": {
      "description": "when the control plane answered but could not queue the wipe"
     },
     "502": {
      "description": "when it did not answer"
     },
     "default": {
      "description": "the MDM control plane's own status and body"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_device_destructive + sudo",
    "x-permissions-named": [
     "manage_device_destructive"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/software": {
   "get": {
    "operationId": "get_user_management_devices_software",
    "tags": [
     "Device Management"
    ],
    "summary": "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.",
    "description": "Reply, as the catalogue words it: 200 a SoftwarePage (typed under Response Objects) { items: [{ name, publisher, deviceCount, platforms, versions: [{ version, count }] }], total, page, pageSize }\n\nAuth: Session + view_devices",
    "responses": {
     "200": {
      "description": "a SoftwarePage (typed under Response Objects) { items: [{ name, publisher, deviceCount, platforms, versions: [{ version, count }] }], total, page, pageSize }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/SoftwarePage"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-typed-objects": [
     "SoftwarePage"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/{id}/recovery-keys": {
   "get": {
    "operationId": "get_user_management_devices_id_recovery_keys",
    "tags": [
     "Device Management"
    ],
    "summary": "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.",
    "description": "Reply, as the catalogue words it: 200 { keys: [{ keyType, volumeId, escrowedAt }] }\n\nGuard: every escrowed key record of the device in one reply (no paging)\n\nAuth: Session + view_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Device primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ keys: [{ keyType, volumeId, escrowedAt }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "keys": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "keyType",
             "volumeId",
             "escrowedAt"
            ]
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/{id}/recovery-key/{type}/reveal": {
   "post": {
    "operationId": "post_user_management_devices_id_recovery_key_type_reveal",
    "tags": [
     "Device Management"
    ],
    "summary": "Reveal an escrowed recovery key. Audited as DEVICE_RECOVERY_KEY_REVEALED at critical level, with the reason.",
    "description": "Request, as the catalogue words it: A DestructiveReason: { reason } — required, at least 3 characters\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: reveals the key again; every reveal is audited at critical level\n\nAuth: Session + manage_device_destructive + sudo",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Device primary key.",
      "x-type-basis": "model"
     },
     {
      "name": "type",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "requestBody": {
     "description": "A DestructiveReason: { reason } — required, at least 3 characters",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DestructiveReason"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ keyType, recoveryKey, volumeId, rotationScheduled }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "no reason | { 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "no key escrowed for this device"
     },
     "500": {
      "description": "when the stored key cannot be decrypted"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_device_destructive + sudo",
    "x-permissions-named": [
     "manage_device_destructive"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/{id}/filevault/rotate": {
   "post": {
    "operationId": "post_user_management_devices_id_filevault_rotate",
    "tags": [
     "Device Management"
    ],
    "summary": "Ask a Mac to replace its FileVault recovery key at the next login (preview). A second request while one is waiting answers 409.",
    "description": "Request, as the catalogue words it: A DestructiveReason: { reason } — required, at least 3 characters, cut to 500\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: 409 while the new key asked for by the first call is still outstanding\n\nAuth: Session + manage_device_destructive + sudo",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Device primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A DestructiveReason: { reason } — required, at least 3 characters, cut to 500",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DestructiveReason"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ requestId, reason: 'manual', requestedAt }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "No further text in the catalogue. | { 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "No further text in the catalogue."
     },
     "409": {
      "description": "when a new key is already requested, or this Mac cannot be re-keyed now"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_device_destructive + sudo",
    "x-permissions-named": [
     "manage_device_destructive"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/agent-rollouts": {
   "get": {
    "operationId": "get_user_management_devices_agent_rollouts",
    "tags": [
     "Device Management"
    ],
    "summary": "Agent version rollouts in progress. Query: ?platform = macos (default), linux or windows. Newest first, not paged.",
    "description": "Reply, as the catalogue words it: 200 { platform, rollouts: AgentRollout[] } (AgentRollout is typed under Response Objects: { id, platform, version, enabled, label, scope, scopeLabel, deviceCount, onTarget, createdBy, createdAt })\n\nAuth: Session + view_devices",
    "responses": {
     "200": {
      "description": "{ platform, rollouts: AgentRollout[] } (AgentRollout is typed under Response Objects: { id, platform, version, enabled, label, scope, scopeLabel, deviceCount, onTarget, createdBy, createdAt })",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "rollouts": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/AgentRollout"
           }
          }
         },
         "x-keys-not-described": [
          "platform"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_devices",
    "x-permissions-named": [
     "view_devices"
    ],
    "x-typed-objects": [
     "AgentRollout"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/user-management/devices/agent-rollouts/{id}/hold": {
   "post": {
    "operationId": "post_user_management_devices_agent_rollouts_id_hold",
    "tags": [
     "Device Management"
    ],
    "summary": "Hold an agent rollout (DELETE /devices/agent-rollouts/:id cancels it). The body sets the state, so the call is safe to repeat.",
    "description": "Request, as the catalogue words it: An AgentRolloutHold: { enabled } — false (or left out) holds the rollout, true resumes it\n\nReply, as the catalogue words it: 200 { ok: true, id, enabled } | 404. DELETE: 200 { ok: true } | 404\n\nGuard: Repeat: safe; a replace-style write (each call is audited)\n\nAuth: Session + manage_devices",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: AgentRollout primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "An AgentRolloutHold: { enabled } — false (or left out) holds the rollout, true resumes it",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AgentRolloutHold"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok: true, id, enabled }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "id",
          "enabled"
         ]
        }
       }
      }
     },
     "404": {
      "description": ". DELETE: 200 { ok: true } | ",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_devices",
    "x-permissions-named": [
     "manage_devices"
    ],
    "x-catalogue-group": "device-management"
   }
  },
  "/identity-admin/api-tokens": {
   "get": {
    "operationId": "get_identity_admin_api_tokens",
    "tags": [
     "Personal API Tokens"
    ],
    "summary": "List your own tokens (metadata only). No query parameters: the whole list comes back, newest first.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Session + create_api_tokens",
    "responses": {
     "200": {
      "description": "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)",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/ApiToken"
         }
        }
       }
      }
     },
     "403": {
      "description": "code SESSION_REQUIRED when the caller is an API token"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + create_api_tokens",
    "x-permissions-named": [
     "create_api_tokens"
    ],
    "x-typed-objects": [
     "ApiToken"
    ],
    "x-catalogue-group": "api-tokens"
   },
   "post": {
    "operationId": "post_identity_admin_api_tokens",
    "tags": [
     "Personal API Tokens"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: 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) }\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + create_api_tokens + sudo",
    "requestBody": {
     "description": "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) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/ApiTokenCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "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)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ApiTokenCreated"
        }
       }
      }
     },
     "400": {
      "description": "no name, expiry out of range or no scopes | 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') | { 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "code MFA_REQUIRED_FOR_API_TOKENS; 403 when the account is not active | code SESSION_REQUIRED when the caller is an API token"
     },
     "409": {
      "description": "when you already have 20 active tokens"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + create_api_tokens + sudo",
    "x-permissions-named": [
     "create_api_tokens"
    ],
    "x-step-up": "required",
    "x-typed-objects": [
     "ApiToken",
     "ApiTokenCreated"
    ],
    "x-catalogue-group": "api-tokens"
   }
  },
  "/identity-admin/api-tokens/scopes": {
   "get": {
    "operationId": "get_identity_admin_api_tokens_scopes",
    "tags": [
     "Personal API Tokens"
    ],
    "summary": "The permissions you may give a token: your own current permissions, minus create_api_tokens.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Session + create_api_tokens",
    "responses": {
     "200": {
      "description": "{ scopes: [{ name:string, module, description }] sorted by name, defaultExpiryDays:integer (90), maxExpiryDays:integer (365) }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "defaultExpiryDays": {
           "type": "integer",
           "description": "90"
          },
          "maxExpiryDays": {
           "type": "integer",
           "description": "365"
          }
         },
         "x-keys-not-described": [
          "scopes"
         ]
        }
       }
      }
     },
     "403": {
      "description": "code SESSION_REQUIRED when the caller is an API token"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + create_api_tokens",
    "x-permissions-named": [
     "create_api_tokens"
    ],
    "x-catalogue-group": "api-tokens"
   }
  },
  "/identity-admin/api-tokens/{id}/revoke": {
   "post": {
    "operationId": "post_identity_admin_api_tokens_id_revoke",
    "tags": [
     "Personal API Tokens"
    ],
    "summary": "Revoke one of your tokens. No body. Safe to repeat: a token that is already revoked returns the same record.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: safe; a token that is already revoked answers 200 with its record unchanged\n\nAuth: Session + create_api_tokens",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ApiToken primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "the ApiToken record",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ApiToken"
        }
       }
      }
     },
     "403": {
      "description": "code SESSION_REQUIRED when the caller is an API token"
     },
     "404": {
      "description": "for an unknown id or a token that is not yours"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + create_api_tokens",
    "x-permissions-named": [
     "create_api_tokens"
    ],
    "x-typed-objects": [
     "ApiToken"
    ],
    "x-catalogue-group": "api-tokens"
   }
  },
  "/identity-admin/api-tokens/all": {
   "get": {
    "operationId": "get_identity_admin_api_tokens_all",
    "tags": [
     "Personal API Tokens"
    ],
    "summary": "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.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Session + manage_security",
    "responses": {
     "200": {
      "description": "ApiToken[]: the same record, with owner set to the owner's e-mail",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/ApiToken"
         }
        }
       }
      }
     },
     "403": {
      "description": "code SESSION_REQUIRED when the caller is an API token"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-typed-objects": [
     "ApiToken"
    ],
    "x-catalogue-group": "api-tokens"
   }
  },
  "/identity-admin/api-tokens/{id}/admin-revoke": {
   "post": {
    "operationId": "post_identity_admin_api_tokens_id_admin_revoke",
    "tags": [
     "Personal API Tokens"
    ],
    "summary": "Revoke any user's token. Safe to repeat, as for the owner's revoke.",
    "description": "Request, as the catalogue words it: An ApiTokenAdminRevoke: { reason } — optional, cut to 200 characters; stored as revokeReason\n\nReply, as the catalogue words it: 200 the ApiToken record | 404 unknown id | 403 code SESSION_REQUIRED when the caller is an API token\n\nGuard: Repeat: safe; a token that is already revoked answers 200 with its record unchanged, and the first reason is kept\n\nAuth: Session + manage_security",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ApiToken primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "An ApiTokenAdminRevoke: { reason } — optional, cut to 200 characters; stored as revokeReason",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/ApiTokenAdminRevoke"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the ApiToken record",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ApiToken"
        }
       }
      }
     },
     "403": {
      "description": "code SESSION_REQUIRED when the caller is an API token"
     },
     "404": {
      "description": "unknown id"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-typed-objects": [
     "ApiToken"
    ],
    "x-catalogue-group": "api-tokens"
   }
  },
  "/auth/sso/status": {
   "get": {
    "operationId": "get_auth_sso_status",
    "tags": [
     "Single Sign-On (OIDC & SAML)"
    ],
    "summary": "Whether SSO is enabled, for the sign-in screen.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Public",
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/SsoStatus"
        }
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public",
    "x-permissions-named": [],
    "x-typed-objects": [
     "SsoStatus"
    ],
    "x-catalogue-group": "sso"
   }
  },
  "/auth/sso/login": {
   "get": {
    "operationId": "get_auth_sso_login",
    "tags": [
     "Single Sign-On (OIDC & SAML)"
    ],
    "summary": "Start a sign-in with whichever protocol is configured (redirects to your identity provider). No parameters.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Public (rate-limited)",
    "responses": {
     "302": {
      "description": "to the identity provider, with a signed sso_flow cookie valid for 10 minutes | to /login?sso_error=config when SSO is off or the provider's login address cannot be built"
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "sso"
   }
  },
  "/auth/sso/callback": {
   "get": {
    "operationId": "get_auth_sso_callback",
    "tags": [
     "Single Sign-On (OIDC & SAML)"
    ],
    "summary": "OIDC redirect target that completes the sign-in.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 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\n\nAuth: Public (rate-limited)",
    "responses": {
     "302": {
      "description": "to / with the session cookie set | to /login?sso=continue when MFA or terms acceptance is still owed | to /login?sso_error=state (missing or mismatched flow cookie), no_account (no active user matches), failed or denied"
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "sso"
   }
  },
  "/auth/sso/pending": {
   "get": {
    "operationId": "get_auth_sso_pending",
    "tags": [
     "Single Sign-On (OIDC & SAML)"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 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\n\nGuard: single use: the cookie is cleared on every call, whatever the outcome; the pending sign-in lives for 5 minutes\n\nAuth: Public (reads the httpOnly sso_pending cookie set by the SSO redirect)",
    "responses": {
     "200": {
      "description": "an SsoPendingStep (typed under Response Objects): the fields of the step still owed, from { mfaRequired, mfaStatus, userId, mfaToken, mfaMethods, termsAcceptanceRequired, termsToken, termsVersion }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/SsoPendingStep"
        }
       }
      }
     },
     "404": {
      "description": "{ error:'No pending SSO sign-in.' } when the cookie is absent",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "410": {
      "description": "{ error:'Your SSO sign-in expired. Please sign in again.' } when the pending sign-in is expired or already redeemed",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (reads the httpOnly sso_pending cookie set by the SSO redirect)",
    "x-permissions-named": [],
    "x-typed-objects": [
     "SsoPendingStep"
    ],
    "x-catalogue-group": "sso"
   }
  },
  "/auth/sso/saml/login": {
   "get": {
    "operationId": "get_auth_sso_saml_login",
    "tags": [
     "Single Sign-On (OIDC & SAML)"
    ],
    "summary": "Start a SAML sign-in. The same handler as /auth/sso/login.",
    "description": "Reply, as the catalogue words it: as /auth/sso/login\n\nAuth: Public (rate-limited)",
    "responses": {
     "default": {
      "description": "as /auth/sso/login"
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "sso"
   }
  },
  "/auth/sso/saml/acs": {
   "post": {
    "operationId": "post_auth_sso_saml_acs",
    "tags": [
     "Single Sign-On (OIDC & SAML)"
    ],
    "summary": "SAML assertion consumer service — your identity provider posts the response here. RelayState is ignored.",
    "description": "Request, as the catalogue words it: form-encoded SAMLResponse (required), at most 100 kB\n\nReply, as the catalogue words it: 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\n\nGuard: the response must answer the request id bound to this browser's sso_flow cookie\n\nAuth: Public (rate-limited)",
    "requestBody": {
     "description": "form-encoded SAMLResponse (required), at most 100 kB",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "302": {
      "description": "to / with the session cookie set | to /login?sso=continue when MFA or terms acceptance is still owed | to /login?sso_error=state (missing or mismatched flow cookie), no_account (no active user matches), failed or denied"
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "sso"
   }
  },
  "/auth/sso/saml/metadata": {
   "get": {
    "operationId": "get_auth_sso_saml_metadata",
    "tags": [
     "Single Sign-On (OIDC & SAML)"
    ],
    "summary": "Service-provider metadata to give your identity provider.",
    "description": "Reply, as the catalogue words it: 200 application/samlmetadata+xml | 502 { error } when the metadata cannot be built\n\nAuth: Public (rate-limited)",
    "responses": {
     "200": {
      "description": "application/samlmetadata+xml"
     },
     "502": {
      "description": "{ error } when the metadata cannot be built",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "sso"
   }
  },
  "/identity-admin/sso/config": {
   "get": {
    "operationId": "get_identity_admin_sso_config",
    "tags": [
     "Single Sign-On (OIDC & SAML)"
    ],
    "summary": "Read the SSO configuration (secrets masked).",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Session + manage_security",
    "responses": {
     "200": {
      "description": "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[] }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/SsoConfig"
        }
       }
      }
     },
     "403": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-typed-objects": [
     "SsoConfig"
    ],
    "x-catalogue-group": "sso"
   },
   "post": {
    "operationId": "post_identity_admin_sso_config",
    "tags": [
     "Single Sign-On (OIDC & SAML)"
    ],
    "summary": "Save the SSO configuration. A replace-style write: sending the same body again stores the same configuration. Audited as SSO_CONFIG_UPDATED.",
    "description": "Request, as the catalogue words it: { 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\n\nReply, as the catalogue words it: 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\n\nAuth: Session + manage_security + sudo",
    "requestBody": {
     "description": "{ 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",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "protocol": {
          "type": "string",
          "enum": [
           "oidc",
           "saml"
          ]
         },
         "saml": {
          "type": "object",
          "x-keys-not-described": [
           "idpEntityId",
           "idpSsoUrl",
           "idpCerts"
          ]
         },
         "jit": {
          "type": "object",
          "x-keys-not-described": [
           "enabled",
           "allowedDomains",
           "defaultRoleId",
           "defaultGroupId"
          ]
         }
        },
        "x-keys-not-described": [
         "enabled",
         "preset",
         "issuer",
         "clientId",
         "clientSecret",
         "mfaAcrValues"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the masked configuration, as the GET"
     },
     "400": {
      "description": "{ 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 | { 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security + sudo",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "sso"
   }
  },
  "/identity-admin/sso/saml/parse-metadata": {
   "post": {
    "operationId": "post_identity_admin_sso_saml_parse_metadata",
    "tags": [
     "Single Sign-On (OIDC & SAML)"
    ],
    "summary": "Parse identity-provider metadata into the form fields. Saves nothing.",
    "description": "Request, as the catalogue words it: { metadataUrl } (an https address the server fetches) or { metadataXmlB64 } (the XML, base64-encoded, at most 700 kB of base64)\n\nReply, as the catalogue words it: 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\n\nAuth: Session + manage_security",
    "requestBody": {
     "description": "{ metadataUrl } (an https address the server fetches) or { metadataXmlB64 } (the XML, base64-encoded, at most 700 kB of base64)",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ idpEntityId, idpSsoUrl, idpCerts[], certs: [{ subject, notAfter, fingerprint256, expired }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "idpCerts": {
           "type": "array"
          },
          "certs": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "subject",
             "notAfter",
             "fingerprint256",
             "expired"
            ]
           }
          }
         },
         "x-keys-not-described": [
          "idpEntityId",
          "idpSsoUrl"
         ]
        }
       }
      }
     },
     "400": {
      "description": "{ error } when neither is sent, the XML cannot be parsed or it carries no signing certificate",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-catalogue-group": "sso"
   }
  },
  "/scim/v2/ServiceProviderConfig": {
   "get": {
    "operationId": "get_scim_v2_ServiceProviderConfig",
    "tags": [
     "SCIM 2.0 Provisioning"
    ],
    "summary": "SCIM discovery document. /scim/v2/ResourceTypes and /scim/v2/Schemas are served too.",
    "description": "Reply, as the catalogue words it: 200 | 401 SCIM error for a missing, malformed, unknown or revoked token | 404 SCIM error for any path outside the listed resources\n\nAuth: SCIM bearer token",
    "responses": {
     "200": {
      "description": "No further text in the catalogue."
     },
     "401": {
      "description": "SCIM error for a missing, malformed, unknown or revoked token"
     },
     "404": {
      "description": "SCIM error for any path outside the listed resources"
     }
    },
    "security": [
     {
      "scimBearer": []
     }
    ],
    "x-auth": "SCIM bearer token",
    "x-permissions-named": [],
    "x-catalogue-group": "scim"
   }
  },
  "/scim/v2/Users": {
   "get": {
    "operationId": "get_scim_v2_Users",
    "tags": [
     "SCIM 2.0 Provisioning"
    ],
    "summary": "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.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: SCIM bearer token",
    "responses": {
     "200": {
      "description": "a ScimListResponse { schemas, totalResults, startIndex, itemsPerPage, Resources } (typed under Response Objects; Resources holds ScimUser or ScimGroup objects)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ScimListResponse"
        }
       }
      }
     },
     "400": {
      "description": "scimType invalidFilter for an attribute that cannot be filtered"
     }
    },
    "security": [
     {
      "scimBearer": []
     }
    ],
    "x-auth": "SCIM bearer token",
    "x-permissions-named": [],
    "x-typed-objects": [
     "ScimListResponse"
    ],
    "x-catalogue-group": "scim"
   },
   "post": {
    "operationId": "post_scim_v2_Users",
    "tags": [
     "SCIM 2.0 Provisioning"
    ],
    "summary": "Create a user. Repeating the same create answers 409; a user this connection deleted earlier is brought back by the POST instead of duplicated.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 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\n\nAuth: SCIM bearer token",
    "requestBody": {
     "description": "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",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "the ScimUser, with a Location header",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ScimUser"
        }
       }
      }
     },
     "400": {
      "description": "scimType invalidValue or invalidSyntax"
     },
     "409": {
      "description": "scimType uniqueness when a live user already has that userName or externalId"
     }
    },
    "security": [
     {
      "scimBearer": []
     }
    ],
    "x-auth": "SCIM bearer token",
    "x-permissions-named": [],
    "x-typed-objects": [
     "ScimUser"
    ],
    "x-catalogue-group": "scim"
   }
  },
  "/scim/v2/Users/{id}": {
   "get": {
    "operationId": "get_scim_v2_Users_id",
    "tags": [
     "SCIM 2.0 Provisioning"
    ],
    "summary": "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.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: SCIM bearer token",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "A string by its use: the SCIM resource id, a string in the SCIM protocol.",
      "x-type-basis": "literal"
     }
    ],
    "responses": {
     "200": {
      "description": "the ScimUser (GET, PUT, PATCH)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ScimUser"
        }
       }
      }
     },
     "204": {
      "description": "(DELETE)"
     },
     "400": {
      "description": "scimType mutability when the sign-in e-mail of an account with elevated access would change"
     },
     "403": {
      "description": "for the primary administrator, a user owned by another directory, or a record another SCIM connection owns"
     },
     "404": {
      "description": "unknown or deleted user"
     },
     "409": {
      "description": "for the support account; 409 scimType uniqueness when a new userName is taken"
     }
    },
    "security": [
     {
      "scimBearer": []
     }
    ],
    "x-auth": "SCIM bearer token",
    "x-permissions-named": [],
    "x-typed-objects": [
     "ScimUser"
    ],
    "x-catalogue-group": "scim"
   }
  },
  "/scim/v2/Groups": {
   "get": {
    "operationId": "get_scim_v2_Groups",
    "tags": [
     "SCIM 2.0 Provisioning"
    ],
    "summary": "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.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: SCIM bearer token",
    "responses": {
     "200": {
      "description": "a ScimListResponse { schemas, totalResults, startIndex, itemsPerPage, Resources } (typed under Response Objects; Resources holds ScimUser or ScimGroup objects)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ScimListResponse"
        }
       }
      }
     },
     "400": {
      "description": "scimType invalidFilter for an attribute that cannot be filtered. POST: 201 the group, with a Location header | scimType invalidValue without displayName"
     },
     "403": {
      "description": "when group creation by SCIM is turned off"
     },
     "409": {
      "description": "scimType uniqueness when the name exists"
     }
    },
    "security": [
     {
      "scimBearer": []
     }
    ],
    "x-auth": "SCIM bearer token",
    "x-permissions-named": [],
    "x-typed-objects": [
     "ScimListResponse"
    ],
    "x-catalogue-group": "scim"
   }
  },
  "/scim/v2/Groups/{id}": {
   "get": {
    "operationId": "get_scim_v2_Groups_id",
    "tags": [
     "SCIM 2.0 Provisioning"
    ],
    "summary": "Read one group. PUT replaces, PATCH updates, DELETE removes.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: SCIM bearer token",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "A string by its use: the SCIM resource id, a string in the SCIM protocol.",
      "x-type-basis": "literal"
     }
    ],
    "responses": {
     "200": {
      "description": "the ScimGroup (GET, PUT, PATCH)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ScimGroup"
        }
       }
      }
     },
     "204": {
      "description": "(DELETE)"
     },
     "403": {
      "description": "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"
     },
     "404": {
      "description": "for an unknown group or one that is not SCIM-managed"
     },
     "409": {
      "description": "scimType uniqueness on a rename to an existing name"
     }
    },
    "security": [
     {
      "scimBearer": []
     }
    ],
    "x-auth": "SCIM bearer token",
    "x-permissions-named": [],
    "x-typed-objects": [
     "ScimGroup"
    ],
    "x-catalogue-group": "scim"
   }
  },
  "/user-management/scim-provisioning/tokens": {
   "get": {
    "operationId": "get_user_management_scim_provisioning_tokens",
    "tags": [
     "SCIM 2.0 Provisioning"
    ],
    "summary": "List SCIM tokens (metadata only). No query parameters: every token, newest first.",
    "description": "Reply, as the catalogue words it: 200 ScimToken[] (typed under Response Objects) { id, name, tokenPrefix, createdBy, createdAt, lastUsedAt, revokedAt, revokedBy, familyId, active, owns: { users, groups } or null }\n\nGuard: bare array, every token (revoked ones included) in one reply, newest first; no paging\n\nAuth: Session + manage_security",
    "responses": {
     "200": {
      "description": "ScimToken[] (typed under Response Objects) { id, name, tokenPrefix, createdBy, createdAt, lastUsedAt, revokedAt, revokedBy, familyId, active, owns: { users, groups } or null }",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/ScimToken"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-typed-objects": [
     "ScimToken"
    ],
    "x-catalogue-group": "scim"
   },
   "post": {
    "operationId": "post_user_management_scim_provisioning_tokens",
    "tags": [
     "SCIM 2.0 Provisioning"
    ],
    "summary": "Issue a SCIM token. Repeating the call issues another token each time. Audited as SCIM_TOKEN_ISSUED.",
    "description": "Request, as the catalogue words it: 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) }\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: issues another token; 409 only once 10 tokens are active\n\nAuth: Session + manage_security",
    "requestBody": {
     "description": "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) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/ScimTokenIssue"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "the ScimToken record plus token (the raw zscim_ value, shown only here)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ScimToken"
        }
       }
      }
     },
     "400": {
      "description": "no name"
     },
     "404": {
      "description": "rotateFromId not found"
     },
     "409": {
      "description": "when 10 tokens are already active"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-typed-objects": [
     "ScimToken"
    ],
    "x-catalogue-group": "scim"
   }
  },
  "/user-management/scim-provisioning/tokens/{id}/revoke": {
   "post": {
    "operationId": "post_user_management_scim_provisioning_tokens_id_revoke",
    "tags": [
     "SCIM 2.0 Provisioning"
    ],
    "summary": "Revoke a SCIM token. No body. Safe to repeat: an already revoked token returns the same record.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 the ScimToken record, without owns | 404 unknown id\n\nGuard: Repeat: safe; a token that is already revoked answers 200 with its record unchanged\n\nAuth: Session + manage_security",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ScimToken primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "the ScimToken record, without owns",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ScimToken"
        }
       }
      }
     },
     "404": {
      "description": "unknown id"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-typed-objects": [
     "ScimToken"
    ],
    "x-catalogue-group": "scim"
   }
  },
  "/user-management/scim-provisioning/tokens/{id}/rebind": {
   "post": {
    "operationId": "post_user_management_scim_provisioning_tokens_id_rebind",
    "tags": [
     "SCIM 2.0 Provisioning"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: A ScimTokenRebind: { fromTokenId } or { unowned: true }\n\nReply, as the catalogue words it: 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\n\nAuth: Session + manage_security",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ScimToken primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A ScimTokenRebind: { fromTokenId } or { unowned: true }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/ScimTokenRebind"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ users, groups } — how many were moved",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "when neither is sent, or both tokens already belong to the same connection"
     },
     "404": {
      "description": "target or source token not found"
     },
     "409": {
      "description": "when the target token is revoked"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-catalogue-group": "scim"
   }
  },
  "/user-management/scim-provisioning/ownership": {
   "get": {
    "operationId": "get_user_management_scim_provisioning_ownership",
    "tags": [
     "SCIM 2.0 Provisioning"
    ],
    "summary": "Count of SCIM-provisioned users and groups that have no owning connection.",
    "description": "Reply, as the catalogue words it: 200 { unowned: { users, groups } }\n\nAuth: Session + manage_security",
    "responses": {
     "200": {
      "description": "{ unowned: { users, groups } }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "unowned": {
           "type": "object",
           "x-keys-not-described": [
            "users",
            "groups"
           ]
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-catalogue-group": "scim"
   }
  },
  "/user-management/scim-provisioning/settings": {
   "get": {
    "operationId": "get_user_management_scim_provisioning_settings",
    "tags": [
     "SCIM 2.0 Provisioning"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 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\n\nAuth: Session + manage_security",
    "responses": {
     "default": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-typed-objects": [
     "ScimSettings"
    ],
    "x-catalogue-group": "scim"
   }
  },
  "/user-management/access-reviews": {
   "get": {
    "operationId": "get_user_management_access_reviews",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "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.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Session + view_access_reviews | manage_access_reviews",
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/AccessReviewCampaign"
         }
        }
       }
      }
     },
     "404": {
      "description": "unknown campaign. /:id/export returns text/csv"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_access_reviews | manage_access_reviews",
    "x-permissions-named": [
     "manage_access_reviews",
     "view_access_reviews"
    ],
    "x-typed-objects": [
     "AccessReviewCampaign",
     "AccessReviewItem",
     "AccessReviewCampaignDetail"
    ],
    "x-catalogue-group": "access-governance"
   },
   "post": {
    "operationId": "post_user_management_access_reviews",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "Start an access-review campaign. Repeating the call starts another campaign; nothing deduplicates by name.",
    "description": "Request, as the catalogue words it: 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 }\n\nReply, as the catalogue words it: 201 the AccessReviewCampaign | 400 no name, or a department scope with no department\n\nGuard: Repeat: starts a second review with its own items; names are not checked for duplicates\n\nAuth: Session + manage_access_reviews",
    "requestBody": {
     "description": "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 }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AccessReviewCampaignCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "the AccessReviewCampaign",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AccessReviewCampaign"
        }
       }
      }
     },
     "400": {
      "description": "no name, or a department scope with no department"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_access_reviews",
    "x-permissions-named": [
     "manage_access_reviews"
    ],
    "x-typed-objects": [
     "AccessReviewCampaign"
    ],
    "x-catalogue-group": "access-governance"
   }
  },
  "/user-management/access-reviews/{id}/close": {
   "post": {
    "operationId": "post_user_management_access_reviews_id_close",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: No body, for close, archive, restore and delete alike.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: 409; a closed review is no longer active, an archived one is already archived, and a restored one is no longer archived\n\nAuth: Session + manage_access_reviews",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: AccessCertificationCampaign primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "the AccessReviewCampaign",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AccessReviewCampaign"
        }
       }
      }
     },
     "404": {
      "description": "No further text in the catalogue."
     },
     "409": {
      "description": "when it is not active (close), already archived (archive) or not archived (restore). DELETE: 200 { deleted: true, id } | when any item has been decided — archive it instead",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_access_reviews",
    "x-permissions-named": [
     "manage_access_reviews"
    ],
    "x-typed-objects": [
     "AccessReviewCampaign"
    ],
    "x-catalogue-group": "access-governance"
   }
  },
  "/user-management/access-reviews/my-queue": {
   "get": {
    "operationId": "get_user_management_access_reviews_my_queue",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "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.",
    "description": "Reply, as the catalogue words it: 200 an AccessReviewQueue (typed under Response Objects) { items: AccessReviewItem[], total, limit, offset, filter, stats: { all, attention, expected, privileged, drift, people } }\n\nAuth: Session (any signed-in user; only items assigned to you are returned)",
    "responses": {
     "200": {
      "description": "an AccessReviewQueue (typed under Response Objects) { items: AccessReviewItem[], total, limit, offset, filter, stats: { all, attention, expected, privileged, drift, people } }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AccessReviewQueue"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (any signed-in user; only items assigned to you are returned)",
    "x-permissions-named": [],
    "x-typed-objects": [
     "AccessReviewItem",
     "AccessReviewQueue"
    ],
    "x-catalogue-group": "access-governance"
   }
  },
  "/user-management/access-reviews/reviewers": {
   "get": {
    "operationId": "get_user_management_access_reviews_reviewers",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "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.",
    "description": "Reply, as the catalogue words it: 200 an array of { email:string }\n\nAuth: Session + manage_access_reviews",
    "responses": {
     "200": {
      "description": "an array of { email:string }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_access_reviews",
    "x-permissions-named": [
     "manage_access_reviews"
    ],
    "x-catalogue-group": "access-governance"
   }
  },
  "/user-management/access-reviews/grant-detail": {
   "get": {
    "operationId": "get_user_management_access_reviews_grant_detail",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "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).",
    "description": "Request, as the catalogue words it: Query: ?accessType and ?accessId; accessId is required for a role or a group.\n\nReply, as the catalogue words it: 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\n\nGuard: read-only and safe to repeat\n\nAuth: Session + certify_access | view_access_reviews | manage_access_reviews, or the reviewer an item for that grant is assigned to",
    "responses": {
     "200": {
      "description": "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 }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AccessGrantDetail"
        }
       }
      }
     },
     "400": {
      "description": "{ error:'accessId is required.' }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "when you hold none of the three permissions and no item for that grant is assigned to you"
     },
     "503": {
      "description": "when your review assignments could not be checked; try again"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + certify_access | view_access_reviews | manage_access_reviews, or the reviewer an item for that grant is assigned to",
    "x-permissions-named": [
     "certify_access",
     "manage_access_reviews",
     "view_access_reviews"
    ],
    "x-typed-objects": [
     "AccessGrantDetail"
    ],
    "x-catalogue-group": "access-governance"
   }
  },
  "/user-management/access-reviews/items/{id}/decide": {
   "post": {
    "operationId": "post_user_management_access_reviews_items_id_decide",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "Keep or revoke one reviewed grant (several at once at /access-reviews/items/bulk-decide). A second decision on the same item answers 409.",
    "description": "Request, as the catalogue words it: An AccessReviewDecision: { decision: 'keep' | 'revoke' (required), note }. Bulk: { ids (1–500), decision, note }\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: 409; an item can be decided once\n\nAuth: Session + certify_access | manage_access_reviews, or the reviewer the item is assigned to",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: AccessCertificationItem primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "An AccessReviewDecision: { decision: 'keep' | 'revoke' (required), note }. Bulk: { ids (1–500), decision, note }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AccessReviewDecision"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the AccessReviewItem",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AccessReviewItem"
        }
       }
      }
     },
     "400": {
      "description": "No further text in the catalogue."
     },
     "403": {
      "description": "for your own access, an item not assigned to you (without manage_access_reviews), or the primary administrator's access"
     },
     "404": {
      "description": "No further text in the catalogue."
     },
     "409": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + certify_access | manage_access_reviews, or the reviewer the item is assigned to",
    "x-permissions-named": [
     "certify_access",
     "manage_access_reviews"
    ],
    "x-typed-objects": [
     "AccessReviewItem"
    ],
    "x-catalogue-group": "access-governance"
   }
  },
  "/user-management/access-reviews/items/{id}/reassign": {
   "post": {
    "operationId": "post_user_management_access_reviews_items_id_reassign",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "Hand a review item to another reviewer (several at /access-reviews/items/bulk-reassign). Safe to repeat: reassigning to the current reviewer changes nothing.",
    "description": "Request, as the catalogue words it: An AccessReviewReassign: { reviewerEmail }. Bulk: { ids (1–500), reviewerEmail }\n\nReply, as the catalogue words it: 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 }\n\nGuard: Repeat: safe while the item is still pending; the reviewer is set again\n\nAuth: Session + manage_access_reviews",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: AccessCertificationItem primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "An AccessReviewReassign: { reviewerEmail }. Bulk: { ids (1–500), reviewerEmail }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AccessReviewReassign"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the AccessReviewItem",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AccessReviewItem"
        }
       }
      }
     },
     "400": {
      "description": "no reviewerEmail, the person whose access it is, or an inactive user"
     },
     "404": {
      "description": "item or user not found"
     },
     "409": {
      "description": "already decided. Bulk: 200 { ok, failed, results }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_access_reviews",
    "x-permissions-named": [
     "manage_access_reviews"
    ],
    "x-typed-objects": [
     "AccessReviewItem"
    ],
    "x-catalogue-group": "access-governance"
   }
  },
  "/user-management/access-reviews/schedules": {
   "get": {
    "operationId": "get_user_management_access_reviews_schedules",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "Recurring review schedules. Not paged; newest first.",
    "description": "Reply, as the catalogue words it: 200 AccessReviewSchedule[] (typed under Response Objects), every schedule in one reply\n\nAuth: Session + view_access_reviews | manage_access_reviews",
    "responses": {
     "200": {
      "description": "AccessReviewSchedule[] (typed under Response Objects), every schedule in one reply",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/AccessReviewSchedule"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_access_reviews | manage_access_reviews",
    "x-permissions-named": [
     "manage_access_reviews",
     "view_access_reviews"
    ],
    "x-typed-objects": [
     "AccessReviewSchedule"
    ],
    "x-catalogue-group": "access-governance"
   },
   "post": {
    "operationId": "post_user_management_access_reviews_schedules",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: 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' }\n\nReply, as the catalogue words it: 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 }\n\nGuard: 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\n\nAuth: Session + manage_access_reviews",
    "requestBody": {
     "description": "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' }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AccessReviewScheduleWrite"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "the AccessReviewSchedule",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AccessReviewSchedule"
        }
       }
      }
     },
     "400": {
      "description": "no name. PATCH: 200 | a firstRunAt that is not a date"
     },
     "404": {
      "description": ". Run: 201 the launched campaign"
     },
     "409": {
      "description": "when the previous run is still open and skipIfOpen is on. DELETE: 200 { deleted: true, id }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_access_reviews",
    "x-permissions-named": [
     "manage_access_reviews"
    ],
    "x-typed-objects": [
     "AccessReviewSchedule"
    ],
    "x-catalogue-group": "access-governance"
   }
  },
  "/user-management/access-reviews/live": {
   "get": {
    "operationId": "get_user_management_access_reviews_live",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "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.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Session + view_access_reviews | manage_access_reviews",
    "responses": {
     "200": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/LiveAccessSnapshot"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_access_reviews | manage_access_reviews",
    "x-permissions-named": [
     "manage_access_reviews",
     "view_access_reviews"
    ],
    "x-typed-objects": [
     "LiveAccessSnapshot"
    ],
    "x-catalogue-group": "access-governance"
   }
  },
  "/user-management/access-reviews/live/revoke": {
   "post": {
    "operationId": "post_user_management_access_reviews_live_revoke",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "Revoke a grant outside a campaign.",
    "description": "Request, as the catalogue words it: A LiveAccessRevoke: { userId, accessType, accessLabel (all required), accessId (required for a role or a group), providerType }\n\nReply, as the catalogue words it: 200 { revoked: true, runId } | 400 | 403 for the primary administrator's access | 404 user not found | 409 for baseline access, which is restored automatically\n\nAuth: Session + manage_access_reviews",
    "requestBody": {
     "description": "A LiveAccessRevoke: { userId, accessType, accessLabel (all required), accessId (required for a role or a group), providerType }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/LiveAccessRevoke"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ revoked: true, runId }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "revoked": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "runId"
         ]
        }
       }
      }
     },
     "400": {
      "description": "No further text in the catalogue."
     },
     "403": {
      "description": "for the primary administrator's access"
     },
     "404": {
      "description": "user not found"
     },
     "409": {
      "description": "for baseline access, which is restored automatically"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_access_reviews",
    "x-permissions-named": [
     "manage_access_reviews"
    ],
    "x-catalogue-group": "access-governance"
   }
  },
  "/user-management/access-requests": {
   "post": {
    "operationId": "post_user_management_access_requests",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "Request access. Repeating the call files another pending request; nothing deduplicates.",
    "description": "Request, as the catalogue words it: 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) }\n\nReply, as the catalogue words it: 201 the AccessRequest, status pending | 400 no itemLabel, or your account could not be resolved | 404 user not found\n\nGuard: Repeat: files a second pending request; an earlier request for the same thing does not block it\n\nAuth: Session + request_access | approve_access_request | manage_users",
    "requestBody": {
     "description": "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) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AccessRequestCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "the AccessRequest, status pending",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AccessRequest"
        }
       }
      }
     },
     "400": {
      "description": "no itemLabel, or your account could not be resolved"
     },
     "404": {
      "description": "user not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + request_access | approve_access_request | manage_users",
    "x-permissions-named": [
     "approve_access_request",
     "manage_users",
     "request_access"
    ],
    "x-typed-objects": [
     "AccessRequest"
    ],
    "x-catalogue-group": "access-governance"
   },
   "get": {
    "operationId": "get_user_management_access_requests",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "All requests, for approvers. Query: ?status to keep one status (for example pending). Not paged; newest first.",
    "description": "Reply, as the catalogue words it: 200 AccessRequest[] (typed under Response Objects), every matching request in one reply\n\nAuth: Session + approve_access_request | manage_users",
    "responses": {
     "200": {
      "description": "AccessRequest[] (typed under Response Objects), every matching request in one reply",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/AccessRequest"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_access_request | manage_users",
    "x-permissions-named": [
     "approve_access_request",
     "manage_users"
    ],
    "x-typed-objects": [
     "AccessRequest"
    ],
    "x-catalogue-group": "access-governance"
   }
  },
  "/user-management/access-requests/mine": {
   "get": {
    "operationId": "get_user_management_access_requests_mine",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "Your own requests. No query parameters; not paged, newest first.",
    "description": "Reply, as the catalogue words it: 200 AccessRequest[] (typed under Response Objects), every request of yours in one reply\n\nAuth: Session + request_access | approve_access_request | manage_users",
    "responses": {
     "200": {
      "description": "AccessRequest[] (typed under Response Objects), every request of yours in one reply",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/AccessRequest"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + request_access | approve_access_request | manage_users",
    "x-permissions-named": [
     "approve_access_request",
     "manage_users",
     "request_access"
    ],
    "x-typed-objects": [
     "AccessRequest"
    ],
    "x-catalogue-group": "access-governance"
   }
  },
  "/user-management/access-requests/{id}/decide": {
   "post": {
    "operationId": "post_user_management_access_requests_id_decide",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: An AccessRequestDecision: { approve (true approves; anything else rejects), comment }. Bulk: { ids (1–500), approve, comment }\n\nReply, as the catalogue words it: 200 the AccessRequest | 403 | 404 | 409 already decided. Bulk: 200 { approve, ok, failed, results: [{ id, ok, status, error }] }\n\nGuard: 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\n\nAuth: Session + approve_access_request | manage_users",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: AccessRequest primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "An AccessRequestDecision: { approve (true approves; anything else rejects), comment }. Bulk: { ids (1–500), approve, comment }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AccessRequestDecision"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the AccessRequest",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AccessRequest"
        }
       }
      }
     },
     "403": {
      "description": "No further text in the catalogue."
     },
     "404": {
      "description": "No further text in the catalogue."
     },
     "409": {
      "description": "already decided. Bulk: 200 { approve, ok, failed, results: [{ id, ok, status, error }] }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_access_request | manage_users",
    "x-permissions-named": [
     "approve_access_request",
     "manage_users"
    ],
    "x-typed-objects": [
     "AccessRequest"
    ],
    "x-catalogue-group": "access-governance"
   }
  },
  "/user-management/access-requests/custom-fields": {
   "get": {
    "operationId": "get_user_management_access_requests_custom_fields",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "The organisation's extra fields on the request form.",
    "description": "Reply, as the catalogue words it: 200 { fields: [{ key, label, type: text | select | number | date, options }] }\n\nGuard: the whole field list in one reply (no paging)\n\nAuth: Session + request_access | approve_access_request | manage_users",
    "responses": {
     "200": {
      "description": "{ fields: [{ key, label, type: text | select | number | date, options }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "fields": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "key",
             "label",
             "type",
             "options"
            ]
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + request_access | approve_access_request | manage_users",
    "x-permissions-named": [
     "approve_access_request",
     "manage_users",
     "request_access"
    ],
    "x-catalogue-group": "access-governance"
   },
   "post": {
    "operationId": "post_user_management_access_requests_custom_fields",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "Replace the extra request fields. A replace-style write, safe to repeat. Audited as ACCESS_REQUEST_CUSTOM_FIELDS_SET.",
    "description": "Request, as the catalogue words it: 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\n\nReply, as the catalogue words it: 200 { fields } as stored\n\nGuard: replaces the whole list. Repeat: safe; the same list is stored again\n\nAuth: Session + manage_users",
    "requestBody": {
     "description": "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",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AccessRequestFieldsWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ fields } as stored",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users",
    "x-permissions-named": [
     "manage_users"
    ],
    "x-catalogue-group": "access-governance"
   }
  },
  "/user-management/departments": {
   "get": {
    "operationId": "get_user_management_departments",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "The department list used by intake, user and job forms. No query parameters; sorted by name, not paged.",
    "description": "Reply, as the catalogue words it: 200 an array of strings (the names, sorted). /departments/detailed (view_users | manage_users | manage_security) returns [{ name, memberCount, governed, variants }]\n\nAuth: 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",
    "responses": {
     "200": {
      "description": "an array of strings (the names, sorted). /departments/detailed (view_users | manage_users | manage_security) returns [{ name, memberCount, governed, variants }]",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "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",
    "x-permissions-named": [
     "conduct_interview",
     "manage_devices",
     "manage_employee_records",
     "manage_recruitment",
     "manage_security",
     "manage_users",
     "manage_workflows",
     "source_candidates",
     "view_employee_records",
     "view_recruitment",
     "view_users"
    ],
    "x-catalogue-group": "access-governance"
   },
   "post": {
    "operationId": "post_user_management_departments",
    "tags": [
     "Access Reviews, Access Requests & Departments"
    ],
    "summary": "Add a department (also /departments/rename and /departments/merge; DELETE /departments/:name). A repeat of the same add answers 409.",
    "description": "Request, as the catalogue words it: A DepartmentCreate: { name } — required, at most 100 characters. Rename: { from, to }. Merge: { from, into }\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: 409; the name then already exists\n\nAuth: Session + manage_users | manage_personas",
    "requestBody": {
     "description": "A DepartmentCreate: { name } — required, at most 100 characters. Rename: { from, to }. Merge: { from, into }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/DepartmentCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ name, departments, detailed }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "No further text in the catalogue. | into itself"
     },
     "404": {
      "description": "No further text in the catalogue. |  | not a governed department"
     },
     "409": {
      "description": "when the name already exists, ignoring case. Rename: 200 { from, to, moved, references, detailed } | when the target exists or a connected HR system owns the department field. Merge: 200 { from, into, moved, references, detailed } | . DELETE: 200 { name, detailed } | while any user is in it",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users | manage_personas",
    "x-permissions-named": [
     "manage_personas",
     "manage_users"
    ],
    "x-catalogue-group": "access-governance"
   }
  },
  "/user-management/users/{id}/export": {
   "get": {
    "operationId": "get_user_management_users_id_export",
    "tags": [
     "User Import, Invites & Bulk Actions"
    ],
    "summary": "Export another user's personal data by id, including a former employee who can no longer export their own. Audited as PEOPLE_DATA_EXPORTED.",
    "description": "Reply, as the catalogue words it: 200 a JSON document; it carries the 200 most recent audit entries for that e-mail | 404 user not found\n\nAuth: Session + manage_users",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "a JSON document; it carries the 200 most recent audit entries for that e-mail"
     },
     "404": {
      "description": "user not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users",
    "x-permissions-named": [
     "manage_users"
    ],
    "x-catalogue-group": "user-admin"
   }
  },
  "/user-management/users/import/template": {
   "get": {
    "operationId": "get_user_management_users_import_template",
    "tags": [
     "User Import, Invites & Bulk Actions"
    ],
    "summary": "Download the CSV import template.",
    "description": "Reply, as the catalogue words it: 200 text/csv attachment with the header email, firstName, lastName, department, title, managerEmail, groups and one example row\n\nAuth: Session + manage_users",
    "responses": {
     "200": {
      "description": "text/csv attachment with the header email, firstName, lastName, department, title, managerEmail, groups and one example row"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users",
    "x-permissions-named": [
     "manage_users"
    ],
    "x-catalogue-group": "user-admin"
   }
  },
  "/user-management/users/import/preview": {
   "post": {
    "operationId": "post_user_management_users_import_preview",
    "tags": [
     "User Import, Invites & Bulk Actions"
    ],
    "summary": "Validate a CSV and preview what would be created. Writes nothing.",
    "description": "Request, as the catalogue words it: { csv (the file text; required, at most 1,048,576 characters and 1000 rows), options: { existing: 'skip' (default) | 'update', sendInvites, addNewValues } }\n\nReply, as the catalogue words it: 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\n\nAuth: Session + manage_users",
    "requestBody": {
     "description": "{ csv (the file text; required, at most 1,048,576 characters and 1000 rows), options: { existing: 'skip' (default) | 'update', sendInvites, addNewValues } }",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "options": {
          "type": "object",
          "x-keys-not-described": [
           "existing",
           "sendInvites",
           "addNewValues"
          ]
         }
        },
        "x-keys-not-described": [
         "csv"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ rows, summary: { total, create, update, skip, error }, newDepartments, newTitles, options }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "summary": {
           "type": "object",
           "x-keys-not-described": [
            "total",
            "create",
            "update",
            "skip",
            "error"
           ]
          }
         },
         "x-keys-not-described": [
          "rows",
          "newDepartments",
          "newTitles",
          "options"
         ]
        }
       }
      }
     },
     "400": {
      "description": "no csv, an empty file, no email column or more than 1000 rows"
     },
     "413": {
      "description": "file too large"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users",
    "x-permissions-named": [
     "manage_users"
    ],
    "x-catalogue-group": "user-admin"
   }
  },
  "/user-management/users/import/commit": {
   "post": {
    "operationId": "post_user_management_users_import_commit",
    "tags": [
     "User Import, Invites & Bulk Actions"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: the same body as the preview\n\nReply, as the catalogue words it: 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\n\nAuth: Session + manage_users",
    "requestBody": {
     "description": "the same body as the preview",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "summary": {
           "type": "object",
           "x-keys-not-described": [
            "total",
            "created",
            "updated",
            "skipped",
            "errors",
            "invitesSent",
            "invitesNotEmailed"
           ]
          },
          "links": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "email",
             "link",
             "expiresAt"
            ]
           }
          }
         },
         "x-keys-not-described": [
          "rows",
          "options"
         ]
        }
       }
      }
     },
     "400": {
      "description": "No further text in the catalogue."
     },
     "413": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users",
    "x-permissions-named": [
     "manage_users"
    ],
    "x-catalogue-group": "user-admin"
   }
  },
  "/user-management/users/{id}/invite/resend": {
   "post": {
    "operationId": "post_user_management_users_id_invite_resend",
    "tags": [
     "User Import, Invites & Bulk Actions"
    ],
    "summary": "Resend the set-password invite. The previous link stops working. Each call issues a new 72-hour link.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: each call makes a new set-password link, which replaces the one before it, and sends the e-mail again\n\nAuth: Session + manage_users",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ sent: true, expiresAt } | { sent: false, reason, link, expiresAt } when the e-mail could not be sent: the link is shown once | { sent: false, reason, expiresAt, linkWithheld: true } when the account holds access you could not grant yourself",
      "content": {
       "application/json": {
        "schema": {
         "anyOf": [
          {
           "type": "object",
           "properties": {
            "sent": {
             "type": "boolean"
            }
           },
           "x-keys-not-described": [
            "expiresAt"
           ]
          },
          {
           "type": "object",
           "properties": {
            "sent": {
             "type": "boolean"
            }
           },
           "x-keys-not-described": [
            "reason",
            "link",
            "expiresAt"
           ]
          },
          {
           "type": "object",
           "properties": {
            "sent": {
             "type": "boolean"
            },
            "linkWithheld": {
             "type": "boolean"
            }
           },
           "x-keys-not-described": [
            "reason",
            "expiresAt"
           ]
          }
         ]
        }
       }
      }
     },
     "404": {
      "description": "No further text in the catalogue."
     },
     "409": {
      "description": "when this account cannot be invited"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users",
    "x-permissions-named": [
     "manage_users"
    ],
    "x-catalogue-group": "user-admin"
   }
  },
  "/user-management/users/{id}/invite/revoke": {
   "post": {
    "operationId": "post_user_management_users_id_invite_revoke",
    "tags": [
     "User Import, Invites & Bulk Actions"
    ],
    "summary": "Revoke the invite link. A second call answers 409, because no invitation is pending any more.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { revoked: true } | 404 | 409 no pending invitation\n\nGuard: Repeat: 409; no invitation is pending any more\n\nAuth: Session + manage_users",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ revoked: true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "revoked": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "No further text in the catalogue."
     },
     "409": {
      "description": "no pending invitation"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users",
    "x-permissions-named": [
     "manage_users"
    ],
    "x-catalogue-group": "user-admin"
   }
  },
  "/user-management/users/bulk": {
   "post": {
    "operationId": "post_user_management_users_bulk",
    "tags": [
     "User Import, Invites & Bulk Actions"
    ],
    "summary": "Change manager, department, persona, status, group or role on up to 500 users at once.",
    "description": "Request, as the catalogue words it: A UserBulkUpdate: { userIds (1–500), action: setManager | setDepartment | setPersona | setStatus | addGroup | removeGroup | addRole | removeRole, value } — setStatus accepts only active, inactive or disabled\n\nReply, as the catalogue words it: 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\n\nGuard: role changes additionally need manage_roles and group changes manage_groups; offboarding, deletion, password resets and MFA resets cannot be done in bulk\n\nAuth: Session + manage_users",
    "requestBody": {
     "description": "A UserBulkUpdate: { userIds (1–500), action: setManager | setDepartment | setPersona | setStatus | addGroup | removeGroup | addRole | removeRole, value } — setStatus accepts only active, inactive or disabled",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/UserBulkUpdate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ updated, skipped: [{ id, reason }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "skipped": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "id",
             "reason"
            ]
           }
          }
         },
         "x-keys-not-described": [
          "updated"
         ]
        }
       }
      }
     },
     "400": {
      "description": "no users, more than 500, an unknown action, or a value that does not exist"
     },
     "403": {
      "description": "No further text in the catalogue."
     },
     "409": {
      "description": "when a connected HR system owns the manager or department field"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users",
    "x-permissions-named": [
     "manage_users"
    ],
    "x-catalogue-group": "user-admin"
   }
  },
  "/user-management/users/{id}/expire-password": {
   "post": {
    "operationId": "post_user_management_users_id_expire_password",
    "tags": [
     "User Import, Invites & Bulk Actions"
    ],
    "summary": "Force a new password at the next sign-in and end every live session for the account. Local accounts only. No body.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { expired: true, id, email } | 404\n\nGuard: 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\n\nAuth: Session + manage_users",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ expired: true, id, email }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "expired": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "id",
          "email"
         ]
        }
       }
      }
     },
     "404": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users",
    "x-permissions-named": [
     "manage_users"
    ],
    "x-catalogue-group": "user-admin"
   }
  },
  "/user-management/users/expire-password": {
   "post": {
    "operationId": "post_user_management_users_expire_password",
    "tags": [
     "User Import, Invites & Bulk Actions"
    ],
    "summary": "The same for up to 500 users. Accounts that would be refused are skipped with a reason.",
    "description": "Request, as the catalogue words it: A PasswordExpiryBatch: { userIds } — 1 to 500 ids\n\nReply, as the catalogue words it: 200 { expired: [{ id, email }], skipped: [{ id, reason }] } | 400 no users or more than 500\n\nGuard: 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\n\nAuth: Session + manage_users",
    "requestBody": {
     "description": "A PasswordExpiryBatch: { userIds } — 1 to 500 ids",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/PasswordExpiryBatch"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ expired: [{ id, email }], skipped: [{ id, reason }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "expired": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "id",
             "email"
            ]
           }
          },
          "skipped": {
           "type": "array",
           "items": {
            "type": "object",
            "x-keys-not-described": [
             "id",
             "reason"
            ]
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "no users or more than 500"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users",
    "x-permissions-named": [
     "manage_users"
    ],
    "x-catalogue-group": "user-admin"
   }
  },
  "/user-management/users/identity-availability": {
   "get": {
    "operationId": "get_user_management_users_identity_availability",
    "tags": [
     "User Import, Invites & Bulk Actions"
    ],
    "summary": "Whether an email or account name is free, with suggestions that follow the naming convention. Query: ?email, ?username, ?firstName, ?lastName, ?excludeUserId.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Session + execute_workflows | manage_users | view_users | view_dashboard",
    "responses": {
     "200": {
      "description": "{ available, usernameAvailable, suggestions (up to 3), usernameSuggestions, onConvention, usernameOnConvention, convention, emailDomain, strategy } — available is null, with error: 'lookup_unavailable', when the lookup itself fails",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + execute_workflows | manage_users | view_users | view_dashboard",
    "x-permissions-named": [
     "execute_workflows",
     "manage_users",
     "view_dashboard",
     "view_users"
    ],
    "x-catalogue-group": "user-admin"
   }
  },
  "/user-management/users/{id}/onboard": {
   "post": {
    "operationId": "post_user_management_users_id_onboard",
    "tags": [
     "User Import, Invites & Bulk Actions"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: A UserOnboard: { personaId, startDate, managerId } — all optional\n\nReply, as the catalogue words it: 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\n\nAuth: Session + manage_users",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A UserOnboard: { personaId, startDate, managerId } — all optional",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/UserOnboard"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "the run"
     },
     "202": {
      "description": "{ accountPrepared, runCreated: false, warning, rehire } when the account was prepared but no run could be created",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "runCreated": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "accountPrepared",
          "warning",
          "rehire"
         ]
        }
       }
      }
     },
     "403": {
      "description": "when the persona grants access you do not hold"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users",
    "x-permissions-named": [
     "manage_users"
    ],
    "x-catalogue-group": "user-admin"
   }
  },
  "/auth/security/users/{userId}/invite": {
   "post": {
    "operationId": "post_auth_security_users_userId_invite",
    "tags": [
     "User Import, Invites & Bulk Actions"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nGuard: sends real e-mail. Repeat: each call sends another e-mail with a new single-use sign-in link\n\nAuth: Session + manage_users",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ sent: true, activated, status }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "sent": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "activated",
          "status"
         ]
        }
       }
      }
     },
     "404": {
      "description": "No further text in the catalogue."
     },
     "409": {
      "description": "when the account is blocked from signing in, no sign-in address is configured, or e-mail (SMTP) is not configured"
     },
     "424": {
      "description": "when the e-mail could not be sent: the account is left unchanged"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users",
    "x-permissions-named": [
     "manage_users"
    ],
    "x-catalogue-group": "user-admin"
   }
  },
  "/auth/invite/inspect": {
   "post": {
    "operationId": "post_auth_invite_inspect",
    "tags": [
     "User Import, Invites & Bulk Actions"
    ],
    "summary": "The set-password page checks an invitation token (body: token). 410 when the link is used, revoked or expired.",
    "description": "Request, as the catalogue words it: { token } — required string, at most 128 characters\n\nReply, as the catalogue words it: 200 an InviteInspection { valid: true, email, firstName, expiresAt, policy } | 400 | 410\n\nGuard: Repeat: safe; reading an invitation does not use it up\n\nAuth: Public (rate-limited)",
    "requestBody": {
     "description": "{ token } — required string, at most 128 characters",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "an InviteInspection { valid: true, email, firstName, expiresAt, policy }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/InviteInspection"
        }
       }
      }
     },
     "400": {
      "description": "No further text in the catalogue."
     },
     "410": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-typed-objects": [
     "InviteInspection"
    ],
    "x-catalogue-group": "user-admin"
   }
  },
  "/auth/invite/accept": {
   "post": {
    "operationId": "post_auth_invite_accept",
    "tags": [
     "User Import, Invites & Bulk Actions"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: An InviteAcceptRequest: { token (required, at most 128 characters), password (required, at most 1024 characters) }\n\nReply, as the catalogue words it: 200 { success: true, email } | 400 { error } when the password fails the policy | 410\n\nGuard: the password must pass the password policy (400 otherwise); the token works once. Repeat: 410; the first accepted call uses the token up\n\nAuth: Public (rate-limited)",
    "requestBody": {
     "description": "An InviteAcceptRequest: { token (required, at most 128 characters), password (required, at most 1024 characters) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/InviteAcceptRequest"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ success: true, email }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "success": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "email"
         ]
        }
       }
      }
     },
     "400": {
      "description": "{ error } when the password fails the policy",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "410": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "user-admin"
   }
  },
  "/user-management/personas/preview/mover-plan": {
   "post": {
    "operationId": "post_user_management_personas_preview_mover_plan",
    "tags": [
     "User Import, Invites & Bulk Actions"
    ],
    "summary": "Preview a role change (body: userId, newPersonaId): what the new persona grants and what would be revoked. Writes nothing.",
    "description": "Request, as the catalogue words it: { userId, newPersonaId } — both required\n\nReply, as the catalogue words it: 200 the plan | 400 a field is missing, or the user already has that role | 404 user or role not found\n\nAuth: Session + manage_users | manage_personas | manage_workflows",
    "requestBody": {
     "description": "{ userId, newPersonaId } — both required",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "the plan"
     },
     "400": {
      "description": "a field is missing, or the user already has that role"
     },
     "404": {
      "description": "user or role not found"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users | manage_personas | manage_workflows",
    "x-permissions-named": [
     "manage_personas",
     "manage_users",
     "manage_workflows"
    ],
    "x-catalogue-group": "user-admin"
   }
  },
  "/user-management/personas/mover": {
   "post": {
    "operationId": "post_user_management_personas_mover",
    "tags": [
     "User Import, Invites & Bulk Actions"
    ],
    "summary": "Start a role-change run (body: userId, newPersonaId, optional keep and revokeGraceDays). Returns 201 with the run.",
    "description": "Request, as the catalogue words it: { userId, newPersonaId (both required), keep, revokeGraceDays }\n\nReply, as the catalogue words it: 201 the run | 400 a required field is missing | 403\n\nGuard: 403 when the new persona grants access the caller does not hold\n\nAuth: Session + manage_users",
    "requestBody": {
     "description": "{ userId, newPersonaId (both required), keep, revokeGraceDays }",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "the run"
     },
     "400": {
      "description": "a required field is missing"
     },
     "403": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_users",
    "x-permissions-named": [
     "manage_users"
    ],
    "x-catalogue-group": "user-admin"
   }
  },
  "/user-management/logs/forwarding": {
   "get": {
    "operationId": "get_user_management_logs_forwarding",
    "tags": [
     "Log Forwarding & Support Access"
    ],
    "summary": "List the log-forwarding (SIEM) destinations. No query parameters; ordered by id, never more than 10.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Session + administrator role",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "destinations": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/LogForwardDestination"
           }
          },
          "health": {
           "$ref": "#/components/schemas/LogForwardingHealth"
          }
         },
         "x-keys-not-described": [
          "types",
          "datadogSites"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + administrator role",
    "x-permissions-named": [],
    "x-administrator-role": true,
    "x-typed-objects": [
     "LogForwardDestination",
     "LogForwardingHealth"
    ],
    "x-catalogue-group": "ops-admin"
   },
   "post": {
    "operationId": "post_user_management_logs_forwarding",
    "tags": [
     "Log Forwarding & Support Access"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: { 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' }\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: adds another destination with the same settings; names are not required to be unique. 400 once 10 destinations exist\n\nAuth: Session + administrator role + sudo",
    "requestBody": {
     "description": "{ 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' }",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "the LogForwardDestination",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/LogForwardDestination"
        }
       }
      }
     },
     "400": {
      "description": "{ error } for an invalid field, a refused address, or an eleventh destination. PUT: 200 the destination | ",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": ". DELETE: 200 { ok: true } | . Reset: 200 { from, to, skippedEvents, destination } | . 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + administrator role + sudo",
    "x-permissions-named": [],
    "x-step-up": "required",
    "x-administrator-role": true,
    "x-typed-objects": [
     "LogForwardDestination"
    ],
    "x-catalogue-group": "ops-admin"
   }
  },
  "/user-management/logs/forwarding/{id}/test": {
   "post": {
    "operationId": "post_user_management_logs_forwarding_id_test",
    "tags": [
     "Log Forwarding & Support Access"
    ],
    "summary": "Send a test event to a destination. It carries a synthetic event, not your audit data, and is audited as LOG_FORWARD_TEST_SENT.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { ok, status, error } — ok is false, with the reason, when delivery failed | 404 unknown destination\n\nGuard: Repeat: each call sends the destination another test event and writes another LOG_FORWARD_TEST_SENT audit entry\n\nAuth: Session + administrator role",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: LogForwardDestination primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ ok, status, error } — ok is false, with the reason, when delivery failed",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "unknown destination"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + administrator role",
    "x-permissions-named": [],
    "x-administrator-role": true,
    "x-catalogue-group": "ops-admin"
   }
  },
  "/user-management/support/access": {
   "get": {
    "operationId": "get_user_management_support_access",
    "tags": [
     "Log Forwarding & Support Access"
    ],
    "summary": "Whether Zonitix support currently has a sign-in to your app.",
    "description": "Reply, as the catalogue words it: 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 }\n\nAuth: Session + manage_security",
    "responses": {
     "200": {
      "description": "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 }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/SupportAccessStatus"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-typed-objects": [
     "SupportAccessStatus",
     "SupportAccessGrant"
    ],
    "x-catalogue-group": "ops-admin"
   },
   "post": {
    "operationId": "post_user_management_support_access",
    "tags": [
     "Log Forwarding & Support Access"
    ],
    "summary": "Grant Zonitix support a sign-in to your app. A second grant while one is active answers 409: revoke first.",
    "description": "Request, as the catalogue words it: 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) }\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: 409 while the access granted by the first call is still active\n\nAuth: Session + administrator role + sudo",
    "requestBody": {
     "description": "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) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/SupportAccessGrantRequest"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ grant, invite }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "{ error } for an invalid field or an unknown role | code ADMINISTRATOR_ACK_REQUIRED | { 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "for a role you cannot grant"
     },
     "409": {
      "description": "when access is already active, or the address belongs to a regular account"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + administrator role + sudo",
    "x-permissions-named": [],
    "x-step-up": "required",
    "x-administrator-role": true,
    "x-catalogue-group": "ops-admin"
   }
  },
  "/user-management/support/access/revoke": {
   "post": {
    "operationId": "post_user_management_support_access_revoke",
    "tags": [
     "Log Forwarding & Support Access"
    ],
    "summary": "Revoke support access. No body. A second call answers 409, because nothing is active.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: 409; there is then no active support access to revoke\n\nAuth: Session + administrator role + sudo",
    "responses": {
     "200": {
      "description": "{ revoked: true, grant }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "revoked": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "grant"
         ]
        }
       }
      }
     },
     "400": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "409": {
      "description": "no active support access, or it had already ended"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + administrator role + sudo",
    "x-permissions-named": [],
    "x-step-up": "required",
    "x-administrator-role": true,
    "x-catalogue-group": "ops-admin"
   }
  },
  "/user-management/support/mailbox": {
   "put": {
    "operationId": "put_user_management_support_mailbox",
    "tags": [
     "Log Forwarding & Support Access"
    ],
    "summary": "Set or clear the default address the Grant support access window is pre-filled with.",
    "description": "Request, as the catalogue words it: A SupportMailboxUpdate: { email } — a valid address, or empty to clear it\n\nReply, as the catalogue words it: 200 { supportMailbox } | 400 not a valid e-mail address\n\nGuard: 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)\n\nAuth: Session + administrator role",
    "requestBody": {
     "description": "A SupportMailboxUpdate: { email } — a valid address, or empty to clear it",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/SupportMailboxUpdate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ supportMailbox }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "not a valid e-mail address"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + administrator role",
    "x-permissions-named": [],
    "x-administrator-role": true,
    "x-catalogue-group": "ops-admin"
   }
  },
  "/user-management/support/diagnostics": {
   "get": {
    "operationId": "get_user_management_support_diagnostics",
    "tags": [
     "Log Forwarding & Support Access"
    ],
    "summary": "Build the diagnostics bundle you can send to support. Audited as DIAGNOSTICS_BUNDLE_GENERATED.",
    "description": "Reply, as the catalogue words it: 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\n\nAuth: Session + manage_security + sudo",
    "responses": {
     "200": {
      "description": "a JSON attachment named zonitix-diagnostics-<timestamp>.json"
     },
     "401": {
      "description": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "503": {
      "description": "when the bundle could not be built"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security + sudo",
    "x-permissions-named": [
     "manage_security"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "ops-admin"
   }
  },
  "/compliance/reports/generate": {
   "post": {
    "operationId": "post_compliance_reports_generate",
    "tags": [
     "Report Engine & Scheduled Reports"
    ],
    "summary": "Build a report on demand.",
    "description": "Request, as the catalogue words it: { 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? }\n\nReply, as the catalogue words it: 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.\n\nGuard: 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.\n\nAuth: Session + view_reporting (licensed)",
    "requestBody": {
     "description": "{ 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? }",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "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.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting (licensed)",
    "x-permissions-named": [
     "view_reporting"
    ],
    "x-licensed": true,
    "x-catalogue-group": "report-engine"
   }
  },
  "/compliance/reports/download": {
   "post": {
    "operationId": "post_compliance_reports_download",
    "tags": [
     "Report Engine & Scheduled Reports"
    ],
    "summary": "Download a report file.",
    "description": "Request, as the catalogue words it: Query ?format= pdf (default) | csv | xlsx | pptx | zip. Body: the same definition as /generate, plus name? used in the file name.\n\nReply, as the catalogue words it: 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.\n\nGuard: Repeat: every zip download archives a new vault run.\n\nAuth: Session + view_reporting (licensed)",
    "requestBody": {
     "description": "Query ?format= pdf (default) | csv | xlsx | pptx | zip. Body: the same definition as /generate, plus name? used in the file name.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "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": {
      "description": "when a stored file fails its integrity check."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting (licensed)",
    "x-permissions-named": [
     "view_reporting"
    ],
    "x-licensed": true,
    "x-catalogue-group": "report-engine"
   }
  },
  "/compliance/reports/schedules": {
   "get": {
    "operationId": "get_compliance_reports_schedules",
    "tags": [
     "Report Engine & Scheduled Reports"
    ],
    "summary": "List scheduled reports.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + view_reporting (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "schedules": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/ReportSchedule"
           }
          },
          "smtpConfigured": {
           "type": "boolean"
          },
          "frameworks": {
           "type": "array",
           "items": {
            "type": "string"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting (licensed)",
    "x-permissions-named": [
     "view_reporting"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "ReportSchedule"
    ],
    "x-catalogue-group": "report-engine"
   },
   "post": {
    "operationId": "post_compliance_reports_schedules",
    "tags": [
     "Report Engine & Scheduled Reports"
    ],
    "summary": "Create a scheduled report (PUT or DELETE /schedules/:id).",
    "description": "Request, as the catalogue words it: { 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.\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Repeat: POST is not deduplicated, so sending it twice creates two schedules. PUT replaces the fields you send.\n\nAuth: Session + manage_compliance (licensed)",
    "requestBody": {
     "description": "{ 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.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ReportSchedule"
        }
       }
      }
     },
     "404": {
      "description": "\"That scheduled report no longer exists.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "ReportSchedule"
    ],
    "x-catalogue-group": "report-engine"
   }
  },
  "/compliance/reports/schedules/{id}/run": {
   "post": {
    "operationId": "post_compliance_reports_schedules_id_run",
    "tags": [
     "Report Engine & Scheduled Reports"
    ],
    "summary": "Run a scheduled report now.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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.\n\nGuard: Sends real email to the schedule's recipients. Repeat: refused with 409 while the first run is still going.\n\nAuth: Session + manage_compliance (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ReportSchedule primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "202": {
      "description": "{ runId: integer, status: 'running' }. The run continues in the background; poll GET /runs or /runs/:id/status. 404 when the schedule is gone",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "runId": {
           "type": "integer"
          }
         },
         "x-keys-not-described": [
          "status"
         ]
        }
       }
      }
     },
     "409": {
      "description": "\"Not started\" with the reason when a run of this schedule is already live."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "report-engine"
   }
  },
  "/compliance/reports/runs": {
   "get": {
    "operationId": "get_compliance_reports_runs",
    "tags": [
     "Report Engine & Scheduled Reports"
    ],
    "summary": "The sealed run history (status at /runs/:id/status, the file at /runs/:id/artifact).",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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.\n\nGuard: /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.\n\nAuth: Session + view_reporting (licensed)",
    "parameters": [
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 50,
       "minimum": 1,
       "maximum": 200
      },
      "description": "Run history: 1 to 200. With vault: 1 to 100. A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "scheduleId",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer"
      }
     },
     {
      "name": "vault",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "Any value selects the evidence vault. The parameters below are read only with it."
     },
     {
      "name": "framework",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "trigger",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "scheduled",
        "manual"
       ]
      }
     },
     {
      "name": "from",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "to",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "cursor",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "The nextCursor of the reply before."
     }
    ],
    "responses": {
     "200": {
      "description": "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.",
      "content": {
       "application/json": {
        "schema": {
         "anyOf": [
          {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/ReportRun"
           }
          },
          {
           "type": "object",
           "properties": {
            "runs": {
             "type": "array",
             "items": {
              "$ref": "#/components/schemas/ReportRun"
             }
            },
            "nextCursor": {
             "type": [
              "string",
              "null"
             ]
            }
           }
          }
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting (licensed)",
    "x-permissions-named": [
     "view_reporting"
    ],
    "x-licensed": true,
    "x-paging-parameters": [
     "limit",
     "cursor"
    ],
    "x-typed-objects": [
     "ReportRun"
    ],
    "x-catalogue-group": "report-engine"
   }
  },
  "/compliance/reports/runs/{id}/hold": {
   "post": {
    "operationId": "post_compliance_reports_runs_id_hold",
    "tags": [
     "Report Engine & Scheduled Reports"
    ],
    "summary": "Put a run on hold, or release it.",
    "description": "Request, as the catalogue words it: A ReportRunHold: { legalHold: true | false }. Without the field the hold is flipped.\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Repeat: safe when you send legalHold; a call with no body flips the hold each time.\n\nAuth: Session + manage_compliance (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ReportRun primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A ReportRunHold: { legalHold: true | false }. Without the field the hold is flipped.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/ReportRunHold"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ id: integer, legalHold: boolean } | { id, legalHold, unchanged: true } when it was already in that state",
      "content": {
       "application/json": {
        "schema": {
         "anyOf": [
          {
           "type": "object",
           "properties": {
            "id": {
             "type": "integer"
            },
            "legalHold": {
             "type": "boolean"
            }
           }
          },
          {
           "type": "object",
           "properties": {
            "unchanged": {
             "type": "boolean"
            }
           },
           "x-keys-not-described": [
            "id",
            "legalHold"
           ]
          }
         ]
        }
       }
      }
     },
     "400": {
      "description": "\"legalHold must be true or false.\""
     },
     "404": {
      "description": "\"That run no longer exists.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "report-engine"
   }
  },
  "/compliance/reports/runs/{id}/share": {
   "post": {
    "operationId": "post_compliance_reports_runs_id_share",
    "tags": [
     "Report Engine & Scheduled Reports"
    ],
    "summary": "Create a share link to a sealed run (list with GET; revoke with DELETE /runs/share/:linkId).",
    "description": "Request, as the catalogue words it: A ReportShareCreate: { format? ('pdf' default | 'csv'), confirmCsv (must be true for csv), ttlHours? (default 168; 1 to 2160), recipientEmail?, recipientLabel? (up to 80 characters) }\n\nReply, as the catalogue words it: 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.\n\nGuard: Repeat: each POST mints another link. Revoking an already revoked link answers { ok: true } again.\n\nAuth: Session + manage_compliance (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ReportRun primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A ReportShareCreate: { format? ('pdf' default | 'csv'), confirmCsv (must be true for csv), ttlHours? (default 168; 1 to 2160), recipientEmail?, recipientLabel? (up to 80 characters) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/ReportShareCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ReportShareLink"
        }
       }
      }
     },
     "404": {
      "description": "when the run or link does not exist."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "ReportShareLink"
    ],
    "x-catalogue-group": "report-engine"
   },
   "get": {
    "operationId": "get_compliance_reports_runs_id_share",
    "tags": [
     "Report Engine & Scheduled Reports"
    ],
    "summary": "List the share links of one sealed run.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { links: ReportShareLink[] } (typed under Response Objects); each carries tokenHint, never the token | 404 \"That evidence run does not exist.\"\n\nAuth: Session + view_reporting (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ReportRun primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ links: ReportShareLink[] } (typed under Response Objects); each carries tokenHint, never the token",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "links": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/ReportShareLink"
           }
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "\"That evidence run does not exist.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting (licensed)",
    "x-permissions-named": [
     "view_reporting"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "ReportShareLink"
    ],
    "x-catalogue-group": "report-engine"
   }
  },
  "/compliance/reports/runs/share/{linkId}": {
   "delete": {
    "operationId": "delete_compliance_reports_runs_share_linkId",
    "tags": [
     "Report Engine & Scheduled Reports"
    ],
    "summary": "Revoke one share link.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { ok: boolean } | 404 \"That share link does not exist.\"\n\nGuard: A revocation is audited as REPORT_SHARE_LINK_REVOKED. Repeat: safe; revoking a link that is already revoked answers 200 again.\n\nAuth: Session + manage_compliance (licensed)",
    "parameters": [
     {
      "name": "linkId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ReportShareLink primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ ok: boolean }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "\"That share link does not exist.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "report-engine"
   }
  },
  "/compliance/reports/runs/share/{linkId}/views": {
   "get": {
    "operationId": "get_compliance_reports_runs_share_linkId_views",
    "tags": [
     "Report Engine & Scheduled Reports"
    ],
    "summary": "Recent public accesses of one share link, served and refused, read back from the audit log.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { linkId, views[] } | 404 \"That share link does not exist.\"\n\nAuth: Session + view_reporting (licensed)",
    "parameters": [
     {
      "name": "linkId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ReportShareLink primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ linkId, views[] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "views": {
           "type": "array"
          }
         },
         "x-keys-not-described": [
          "linkId"
         ]
        }
       }
      }
     },
     "404": {
      "description": "\"That share link does not exist.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting (licensed)",
    "x-permissions-named": [
     "view_reporting"
    ],
    "x-licensed": true,
    "x-catalogue-group": "report-engine"
   }
  },
  "/compliance/reports/saved": {
   "get": {
    "operationId": "get_compliance_reports_saved",
    "tags": [
     "Report Engine & Scheduled Reports"
    ],
    "summary": "Saved report definitions (POST to save one, DELETE /saved/:id).",
    "description": "Request, as the catalogue words it: POST: { name (required, up to 120 characters), format? ('table' | 'pdf') } plus the same definition fields as /generate.\n\nReply, as the catalogue words it: 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.\n\nGuard: Repeat: POST is not deduplicated.\n\nAuth: Session + view_reporting (licensed)",
    "responses": {
     "200": {
      "description": "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 }",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/SavedReport"
         }
        }
       }
      }
     },
     "403": {
      "description": "\"That saved report belongs to someone else.\""
     },
     "404": {
      "description": "."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting (licensed)",
    "x-permissions-named": [
     "view_reporting"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "SavedReport"
    ],
    "x-catalogue-group": "report-engine"
   }
  },
  "/compliance/reports/vault/archive": {
   "post": {
    "operationId": "post_compliance_reports_vault_archive",
    "tags": [
     "Report Engine & Scheduled Reports"
    ],
    "summary": "Archive a report into the vault now.",
    "description": "Request, as the catalogue words it: The same definition as /generate; name? defaults to 'ad-hoc evidence'.\n\nReply, as the catalogue words it: 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.\n\nGuard: Nothing is emailed. Repeat: a double click seals once; the second answer carries the same run id and coalesced: true.\n\nAuth: Session + view_reporting (licensed)",
    "requestBody": {
     "description": "The same definition as /generate; name? defaults to 'ad-hoc evidence'.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ id: integer, framework: string | null, readinessScore: integer | null, artifactHash: string | null, coalesced?: true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "integer"
          },
          "framework": {
           "type": [
            "string",
            "null"
           ]
          },
          "readinessScore": {
           "type": [
            "integer",
            "null"
           ]
          },
          "artifactHash": {
           "type": [
            "string",
            "null"
           ]
          },
          "coalesced": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "with the reason when the report could not be generated."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting (licensed)",
    "x-permissions-named": [
     "view_reporting"
    ],
    "x-licensed": true,
    "x-catalogue-group": "report-engine"
   }
  },
  "/compliance/reports/compliance/policies": {
   "get": {
    "operationId": "get_compliance_reports_compliance_policies",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "The policy register.",
    "description": "Reply, as the catalogue words it: 200 { policies: Policy[] } (typed under Response Objects), sorted by name, without the policy text. The register holds at most 200 policies.\n\nAuth: Session + view_reporting | manage_compliance (licensed)",
    "responses": {
     "200": {
      "description": "{ policies: Policy[] } (typed under Response Objects), sorted by name, without the policy text. The register holds at most 200 policies.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "policies": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/Policy"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Policy"
    ],
    "x-catalogue-group": "compliance-hub"
   },
   "post": {
    "operationId": "post_compliance_reports_compliance_policies",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "Add a policy (PUT or DELETE /policies/:id).",
    "description": "Request, as the catalogue words it: { 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) }\n\nReply, as the catalogue words it: 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\".\n\nGuard: 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.\n\nAuth: Session + manage_compliance (licensed)",
    "requestBody": {
     "description": "{ 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) }",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Policy"
        }
       }
      }
     },
     "404": {
      "description": "\"Policy not found\"."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Policy"
    ],
    "x-catalogue-group": "compliance-hub"
   }
  },
  "/compliance/reports/compliance/policies/{id}/ack": {
   "post": {
    "operationId": "post_compliance_reports_compliance_policies_id_ack",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors",
     "Policy Signing & People Evidence"
    ],
    "summary": "Sign a policy as yourself (your own list at /policies/ack/mine). See Policy Signing & People Evidence for the signing flow.",
    "description": "Request, as the catalogue words it: { signedName: string (2 characters or more), contentHash: string (the hash of the text you read), sections: string[] (the hash of every section, as confirmed) }\n\nReply, as the catalogue words it: 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.\"\n\nGuard: 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.\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "requestBody": {
     "description": "{ signedName: string (2 characters or more), contentHash: string (the hash of the text you read), sections: string[] (the hash of every section, as confirmed) }",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "signedName": {
          "type": "string",
          "description": "2 characters or more"
         },
         "contentHash": {
          "type": "string",
          "description": "the hash of the text you read"
         },
         "sections": {
          "type": "array",
          "items": {
           "type": "string"
          },
          "description": "the hash of every section, as confirmed"
         }
        }
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ 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.\"",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "policyId": {
           "type": "string"
          },
          "name": {
           "type": "string"
          },
          "version": {
           "type": "string"
          },
          "contentHash": {
           "type": "string"
          },
          "sections": {
           "type": "integer"
          },
          "signedName": {
           "type": "string"
          },
          "alreadyAcknowledged": {
           "type": "boolean"
          },
          "renewal": {
           "type": "boolean"
          },
          "kind": {
           "type": "string",
           "enum": [
            "first",
            "renewal",
            "text-changed"
           ]
          },
          "nameMatches": {
           "type": [
            "boolean",
            "null"
           ]
          }
         },
         "x-keys-not-described": [
          "acknowledgedAt"
         ]
        }
       }
      }
     },
     "400": {
      "description": "\"Confirm every section before you sign ...\""
     },
     "401": {
      "description": "\"No authenticated user.\""
     },
     "404": {
      "description": "\"That policy is not available for signing.\""
     },
     "409": {
      "description": "\"This policy changed while you were reading it. Reload and read the current text.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "compliance-hub"
   }
  },
  "/compliance/reports/compliance/risks": {
   "get": {
    "operationId": "get_compliance_reports_compliance_risks",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "The risk register.",
    "description": "Reply, as the catalogue words it: 200 { risks: Risk[] } (typed under Response Objects), highest score first, then most recently updated.\n\nGuard: Everything in one answer; no paging parameters are read.\n\nAuth: Session + view_reporting | manage_compliance (licensed)",
    "responses": {
     "200": {
      "description": "{ risks: Risk[] } (typed under Response Objects), highest score first, then most recently updated.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "risks": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/Risk"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Risk"
    ],
    "x-catalogue-group": "compliance-hub"
   },
   "post": {
    "operationId": "post_compliance_reports_compliance_risks",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "Add a risk (PUT or DELETE /risks/:id).",
    "description": "Request, as the catalogue words it: { 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) }\n\nReply, as the catalogue words it: 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\".\n\nGuard: 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.\n\nAuth: Session + manage_compliance (licensed)",
    "requestBody": {
     "description": "{ 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) }",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Risk"
        }
       }
      }
     },
     "404": {
      "description": "\"Risk not found\"."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Risk"
    ],
    "x-catalogue-group": "compliance-hub"
   }
  },
  "/compliance/reports/compliance/vendors": {
   "get": {
    "operationId": "get_compliance_reports_compliance_vendors",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "The vendor register.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + view_reporting | manage_compliance (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "RegistersVendor",
     "FinanceVendor"
    ],
    "x-catalogue-group": "compliance-hub"
   },
   "post": {
    "operationId": "post_compliance_reports_compliance_vendors",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "Add a vendor (PUT or DELETE /vendors/:id; reviews at /vendors/:id/request-review and /complete-review).",
    "description": "Request, as the catalogue words it: { 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? }\n\nReply, as the catalogue words it: 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\".\n\nGuard: 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.\n\nAuth: Session + manage_compliance (licensed)",
    "requestBody": {
     "description": "{ 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? }",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "\"Vendor not found\"."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "RegistersVendor",
     "FinanceVendor"
    ],
    "x-catalogue-group": "compliance-hub"
   }
  },
  "/compliance/reports/compliance/soa": {
   "get": {
    "operationId": "get_compliance_reports_compliance_soa",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "The Statement of Applicability.",
    "description": "Request, as the catalogue words it: Query ?framework= (required, a catalogue framework).\n\nReply, as the catalogue words it: 400 \"A valid framework is required.\" without it.\n\nAuth: Session + view_reporting | manage_compliance (licensed)",
    "responses": {
     "400": {
      "description": "\"A valid framework is required.\" without it."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-licensed": true,
    "x-catalogue-group": "compliance-hub"
   }
  },
  "/compliance/reports/compliance/type-two": {
   "get": {
    "operationId": "get_compliance_reports_compliance_type_two",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "How each control of one framework held over a period, counted from the automatic snapshots taken in that period.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 200 a TypeTwoReport (typed under Response Objects) | 400 \"A valid framework is required.\"\n\nAuth: Session + view_reporting | manage_compliance (licensed)",
    "responses": {
     "200": {
      "description": "a TypeTwoReport (typed under Response Objects)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/TypeTwoReport"
        }
       }
      }
     },
     "400": {
      "description": "\"A valid framework is required.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "TypeTwoReport"
    ],
    "x-catalogue-group": "compliance-hub"
   }
  },
  "/compliance/reports/compliance/environment": {
   "get": {
    "operationId": "get_compliance_reports_compliance_environment",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "The environment profile: the systems your organisation has declared it uses.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 an EnvironmentProfile (typed under Response Objects); empty lists and null stamps before the first save.\n\nAuth: Session + view_reporting | manage_compliance (licensed)",
    "responses": {
     "200": {
      "description": "an EnvironmentProfile (typed under Response Objects); empty lists and null stamps before the first save.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/EnvironmentProfile"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "EnvironmentProfile"
    ],
    "x-catalogue-group": "compliance-hub"
   },
   "post": {
    "operationId": "post_compliance_reports_compliance_environment",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "Replace the environment profile.",
    "description": "Request, as the catalogue words it: { 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.\n\nReply, as the catalogue words it: 200 the EnvironmentProfile as saved\n\nGuard: Replaces the whole profile; safe to repeat. Audited as COMPLIANCE_ENVIRONMENT_UPDATED.\n\nAuth: Session + manage_compliance (licensed)",
    "requestBody": {
     "description": "{ 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.",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "selected": {
          "type": "array",
          "items": {
           "type": "string"
          },
          "description": "duplicates dropped, at most 300 kept"
         },
         "custom": {
          "type": "array",
          "items": {
           "type": "object",
           "x-keys-not-described": [
            "name",
            "id",
            "category"
           ]
          },
          "description": "at most 100 kept"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the EnvironmentProfile as saved",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/EnvironmentProfile"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "EnvironmentProfile"
    ],
    "x-catalogue-group": "compliance-hub"
   }
  },
  "/compliance/reports/compliance/soa/seal": {
   "post": {
    "operationId": "post_compliance_reports_compliance_soa_seal",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "Seal the current Statement of Applicability.",
    "description": "Request, as the catalogue words it: { framework } (or ?framework=)\n\nReply, as the catalogue words it: 201 { runId, framework, sealed, generatedAt } | 400 \"A valid framework is required.\"\n\nGuard: Download the sealed file through /compliance/reports/runs/:runId/artifact. Repeat: each call seals a new run.\n\nAuth: Session + manage_compliance (licensed)",
    "requestBody": {
     "description": "{ framework } (or ?framework=)",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ runId, framework, sealed, generatedAt }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "\"A valid framework is required.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "compliance-hub"
   }
  },
  "/compliance/reports/compliance/evidence": {
   "get": {
    "operationId": "get_compliance_reports_compliance_evidence",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "Evidence attached to controls.",
    "description": "Request, as the catalogue words it: Query ?controlId= (without it the list is empty), framework?\n\nReply, as the catalogue words it: 200 { items: EvidenceItem[] } (typed under Response Objects), newest first, at most 100 | 403 \"You can only view evidence for a control assigned to you.\"\n\nAuth: Session + view_reporting | manage_compliance | view_assigned_compliance (licensed)",
    "responses": {
     "200": {
      "description": "{ items: EvidenceItem[] } (typed under Response Objects), newest first, at most 100",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "items": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/EvidenceItem"
           }
          }
         }
        }
       }
      }
     },
     "403": {
      "description": "\"You can only view evidence for a control assigned to you.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance | view_assigned_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "view_assigned_compliance",
     "view_reporting"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "EvidenceItem"
    ],
    "x-catalogue-group": "compliance-hub"
   },
   "post": {
    "operationId": "post_compliance_reports_compliance_evidence",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "Attach evidence to a control (DELETE /evidence/:id removes it).",
    "description": "Request, as the catalogue words it: 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? }\n\nReply, as the catalogue words it: 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.\n\nGuard: 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.\n\nAuth: Session + manage_compliance | view_assigned_compliance (licensed)",
    "requestBody": {
     "description": "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? }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/EvidenceItemCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/EvidenceItem"
        }
       }
      }
     },
     "403": {
      "description": "when the control is not assigned to you, or on DELETE when you did not attach it."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance | view_assigned_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "view_assigned_compliance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "EvidenceItem"
    ],
    "x-catalogue-group": "compliance-hub"
   }
  },
  "/compliance/reports/compliance/trust-center": {
   "get": {
    "operationId": "get_compliance_reports_compliance_trust_center",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "Read the trust-center settings.",
    "description": "Reply, as the catalogue words it: 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 }\n\nAuth: Session + view_reporting | manage_compliance (licensed)",
    "responses": {
     "200": {
      "description": "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 }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/TrustCenterSettings"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "TrustCenterSettings"
    ],
    "x-catalogue-group": "compliance-hub"
   },
   "post": {
    "operationId": "post_compliance_reports_compliance_trust_center",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "Change the trust-center settings, including whether it is published.",
    "description": "Request, as the catalogue words it: { 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) }\n\nReply, as the catalogue words it: 200 the TrustCenterSettings as saved.\n\nGuard: 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.\n\nAuth: Session + manage_compliance (licensed)",
    "requestBody": {
     "description": "{ 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) }",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "the TrustCenterSettings as saved.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/TrustCenterSettings"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "TrustCenterSettings"
    ],
    "x-catalogue-group": "compliance-hub"
   }
  },
  "/compliance/reports/compliance/findings/{controlId}/affected": {
   "get": {
    "operationId": "get_compliance_reports_compliance_findings_controlId_affected",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "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).",
    "description": "Request, as the catalogue words it: Query string, not a JSON body: ?framework, ?q, ?limit, ?cursor.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + view_reporting | manage_compliance (licensed)",
    "parameters": [
     {
      "name": "controlId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Typed from the primary key of the record it names: the ControlState primary key is a string, the control's id in its framework.",
      "x-type-basis": "model"
     },
     {
      "name": "framework",
      "in": "query",
      "required": true,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "q",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "A longer value is cut to 200 characters."
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 50,
       "minimum": 1,
       "maximum": 200
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "cursor",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "The nextCursor of the reply before."
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "datasets": {
           "type": "array",
           "items": {
            "type": "string"
           }
          },
          "rows": {
           "type": "array"
          },
          "total": {
           "type": "integer",
           "description": "rows matching the search"
          },
          "limit": {
           "type": "integer"
          },
          "exactCount": {
           "type": "integer"
          },
          "populationRows": {
           "type": "integer"
          },
          "truncated": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "framework",
          "controlId",
          "control",
          "nextCursor",
          "issues",
          "fixLink",
          "asOf",
          "note"
         ]
        }
       }
      }
     },
     "400": {
      "description": "{ error } for an unknown framework or a control id that is not 1 to 64 letters, digits, dots, hyphens or underscores",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "{ error } naming the permission needed, when the control's checks read user accounts or audit-log entries your permissions do not cover",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "{ error } when the control is not part of that framework",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-licensed": true,
    "x-paging-parameters": [
     "limit",
     "cursor"
    ],
    "x-catalogue-group": "compliance-hub"
   }
  },
  "/compliance/reports/compliance/findings/{controlId}/affected.csv": {
   "get": {
    "operationId": "get_compliance_reports_compliance_findings_controlId_affected_csv",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "The same list as a CSV file: every row that matches the search, not one page. Query: ?framework (required) and ?q.",
    "description": "Request, as the catalogue words it: Query string, not a JSON body: ?framework, ?q.\n\nReply, as the catalogue words it: 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\n\nGuard: not paged: every matching row in one file. Each download is written to the audit log as COMPLIANCE_FINDING_AFFECTED_EXPORTED\n\nAuth: Session + view_reporting | manage_compliance (licensed)",
    "parameters": [
     {
      "name": "controlId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Typed from the primary key of the record it names: the ControlState primary key is a string, the control's id in its framework.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "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": {
      "description": ", 403 and 404 as the paged read"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-licensed": true,
    "x-catalogue-group": "compliance-hub"
   }
  },
  "/compliance/reports/compliance/auditor-shares": {
   "post": {
    "operationId": "post_compliance_reports_compliance_auditor_shares",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "Create an auditor workspace link (list with GET; revoke with DELETE /auditor-shares/:id).",
    "description": "Request, as the catalogue words it: { framework (required, a catalogue framework), ttlDays? (default 30; 1 to 180), label? (up to 80 characters) }\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Repeat: each POST mints another link; revoking twice answers the same.\n\nAuth: Session + manage_compliance (licensed)",
    "requestBody": {
     "description": "{ framework (required, a catalogue framework), ttlDays? (default 30; 1 to 180), label? (up to 80 characters) }",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "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 }",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AuditorShare"
        }
       }
      }
     },
     "404": {
      "description": "\"Share not found\". 400 \"A valid framework is required.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "AuditorShare"
    ],
    "x-catalogue-group": "compliance-hub"
   }
  },
  "/compliance/reports/compliance/auditor-requests": {
   "get": {
    "operationId": "get_compliance_reports_compliance_auditor_requests",
    "tags": [
     "Compliance Hub: Policies, Risks, Vendors & Auditors"
    ],
    "summary": "Evidence requests auditors have raised (resolve at /auditor-requests/:id/resolve, manage_compliance).",
    "description": "Request, as the catalogue words it: Query status? (open | resolved), framework?. Resolve: { response? (up to 500 characters) }\n\nReply, as the catalogue words it: 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.\n\nGuard: 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.\n\nAuth: Session + view_reporting | manage_compliance (licensed)",
    "responses": {
     "200": {
      "description": "{ requests: AuditorRequest[] } (typed under Response Objects), newest first. Resolve 200 the AuditorRequest",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "requests": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/AuditorRequest"
           }
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "\"Request not found\""
     },
     "409": {
      "description": "\"This request was already resolved.\" with respondedBy and respondedAt."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "AuditorRequest"
    ],
    "x-catalogue-group": "compliance-hub"
   }
  },
  "/shared/trust": {
   "get": {
    "operationId": "get_shared_trust",
    "tags": [
     "Public Share Links"
    ],
    "summary": "Your public trust center. Answers 404 until you publish it.",
    "description": "Reply, as the catalogue words it: 200 an HTML page, sent with Cache-Control: no-store | 404 \"No trust center is published.\"\n\nAuth: Public",
    "responses": {
     "200": {
      "description": "an HTML page, sent with Cache-Control: no-store"
     },
     "404": {
      "description": "\"No trust center is published.\""
     }
    },
    "security": [],
    "x-auth": "Public",
    "x-permissions-named": [],
    "x-catalogue-group": "shared-links"
   }
  },
  "/shared/{token}": {
   "get": {
    "operationId": "get_shared_token",
    "tags": [
     "Public Share Links"
    ],
    "summary": "A sealed report, by share link.",
    "description": "Request, as the catalogue words it: No query is read. The link serves the one format chosen when it was created (pdf or csv).\n\nReply, as the catalogue words it: 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.\n\nGuard: 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.\n\nAuth: Share-link token",
    "parameters": [
     {
      "name": "token",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[a-f0-9]{64}$"
      },
      "description": "Typed from the route's own check: the gateway accepts 64 lower-case hexadecimal characters.",
      "x-type-basis": "route"
     },
     {
      "name": "format",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "default": "pdf",
       "enum": [
        "csv",
        "pdf"
       ]
      },
      "description": "Any value other than csv answers the PDF."
     }
    ],
    "responses": {
     "200": {
      "description": "the sealed file inline, with headers X-Artifact-SHA256 and X-Artifact-Integrity (verified | unsealed)"
     },
     "404": {
      "description": "\"This link is not valid.\""
     },
     "409": {
      "description": "when the stored file fails its integrity check"
     },
     "410": {
      "description": "\"This link has expired or been revoked.\""
     },
     "503": {
      "description": "when the link's creator cannot be checked right now."
     }
    },
    "security": [
     {
      "namedInXAuth": []
     }
    ],
    "x-auth": "Share-link token",
    "x-permissions-named": [],
    "x-catalogue-group": "shared-links"
   }
  },
  "/shared/verify/{runId}": {
   "get": {
    "operationId": "get_shared_verify_runId",
    "tags": [
     "Public Share Links"
    ],
    "summary": "Verify that a sealed report is genuine.",
    "description": "Request, as the catalogue words it: Query ?sha256= (required: the file's SHA-256, 64 hex characters).\n\nReply, as the catalogue words it: 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.\n\nGuard: A wrong run id and an altered file give the same match: false answer.\n\nAuth: Public (rate-limited)",
    "parameters": [
     {
      "name": "runId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer",
       "minimum": 1
      },
      "description": "Typed from the route's own check: the gateway accepts 1 to 12 digits, not starting with 0.",
      "x-type-basis": "route"
     },
     {
      "name": "sha256",
      "in": "query",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[a-fA-F0-9]{64}$"
      },
      "description": "The SHA-256 of the file, 64 hexadecimal characters."
     }
    ],
    "responses": {
     "200": {
      "description": "{ match: true, runId: integer, framework: string | null, observationWindow: { from: timestamp | null, to: timestamp | null }, generatedAt: timestamp, signature?: object (PDF only) } | { match: false, runId: integer, message: string }",
      "content": {
       "application/json": {
        "schema": {
         "anyOf": [
          {
           "type": "object",
           "properties": {
            "match": {
             "type": "boolean"
            },
            "runId": {
             "type": "integer"
            },
            "framework": {
             "type": [
              "string",
              "null"
             ]
            },
            "observationWindow": {
             "type": "object",
             "x-keys-not-described": [
              "from",
              "to"
             ]
            },
            "signature": {
             "type": "object",
             "description": "PDF only"
            }
           },
           "x-keys-not-described": [
            "generatedAt"
           ]
          },
          {
           "type": "object",
           "properties": {
            "match": {
             "type": "boolean"
            },
            "runId": {
             "type": "integer"
            },
            "message": {
             "type": "string"
            }
           }
          }
         ]
        }
       }
      }
     },
     "400": {
      "description": "without a valid sha256"
     },
     "404": {
      "description": "for a run id that is not a number."
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "shared-links"
   }
  },
  "/shared/auditor/{token}": {
   "get": {
    "operationId": "get_shared_auditor_token",
    "tags": [
     "Public Share Links"
    ],
    "summary": "The auditor workspace, by link.",
    "description": "Reply, as the catalogue words it: 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.\"\n\nAuth: Auditor-link token",
    "parameters": [
     {
      "name": "token",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[a-f0-9]{64}$"
      },
      "description": "Typed from the route's own check: the gateway accepts 64 lower-case hexadecimal characters.",
      "x-type-basis": "route"
     }
    ],
    "responses": {
     "200": {
      "description": "a read-only HTML page for the link's one framework"
     },
     "404": {
      "description": "\"This link is not valid.\""
     },
     "410": {
      "description": "\"This link has expired or been revoked.\""
     }
    },
    "security": [
     {
      "namedInXAuth": []
     }
    ],
    "x-auth": "Auditor-link token",
    "x-permissions-named": [],
    "x-catalogue-group": "shared-links"
   }
  },
  "/shared/auditor/{token}/requests": {
   "post": {
    "operationId": "post_shared_auditor_token_requests",
    "tags": [
     "Public Share Links"
    ],
    "summary": "An auditor raises an evidence request.",
    "description": "Request, as the catalogue words it: An AuditorEvidenceRequestCreate: { controlId (required, a control of the link's framework), note (required, up to 500 characters) }\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Repeat: not deduplicated; each call opens another request until the link's open-request limit answers 429.\n\nAuth: Auditor-link token",
    "parameters": [
     {
      "name": "token",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[a-f0-9]{64}$"
      },
      "description": "Typed from the route's own check: the gateway accepts 64 lower-case hexadecimal characters.",
      "x-type-basis": "route"
     }
    ],
    "requestBody": {
     "description": "An AuditorEvidenceRequestCreate: { controlId (required, a control of the link's framework), note (required, up to 500 characters) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AuditorEvidenceRequestCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ ok: true, id: string }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "id": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"Unknown control for this workspace.\" or \"A note is required.\""
     },
     "404": {
      "description": "No further text in the catalogue."
     },
     "410": {
      "description": "No further text in the catalogue."
     },
     "429": {
      "description": "\"Too many open requests on this link.\""
     }
    },
    "security": [
     {
      "namedInXAuth": []
     }
    ],
    "x-auth": "Auditor-link token",
    "x-permissions-named": [],
    "x-catalogue-group": "shared-links"
   }
  },
  "/compliance/reports/backups": {
   "get": {
    "operationId": "get_compliance_reports_backups",
    "tags": [
     "Backups, Restore & Release Channel"
    ],
    "summary": "List backups.",
    "description": "Request, as the catalogue words it: Query ?recheck=1 re-checks off-server copies instead of using the cached answer.\n\nReply, as the catalogue words it: 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.\n\nAuth: Session + view_system",
    "responses": {
     "200": {
      "description": "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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/BackupPoint"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_system",
    "x-permissions-named": [
     "view_system"
    ],
    "x-typed-objects": [
     "BackupPoint"
    ],
    "x-catalogue-group": "backup-ops"
   }
  },
  "/compliance/reports/backup-audit": {
   "get": {
    "operationId": "get_compliance_reports_backup_audit",
    "tags": [
     "Backups, Restore & Release Channel"
    ],
    "summary": "The backup audit trail.",
    "description": "Reply, as the catalogue words it: 200 BackupAuditEntry[] (typed under Response Objects): a bare array of the newest 40 entries. No paging parameters are read.\n\nAuth: Session + view_system",
    "responses": {
     "200": {
      "description": "BackupAuditEntry[] (typed under Response Objects): a bare array of the newest 40 entries. No paging parameters are read.",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/BackupAuditEntry"
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_system",
    "x-permissions-named": [
     "view_system"
    ],
    "x-typed-objects": [
     "BackupAuditEntry"
    ],
    "x-catalogue-group": "backup-ops"
   }
  },
  "/compliance/reports/wal-archive": {
   "get": {
    "operationId": "get_compliance_reports_wal_archive",
    "tags": [
     "Backups, Restore & Release Channel"
    ],
    "summary": "Write-ahead-log archive status.",
    "description": "Request, as the catalogue words it: Query ?list=1 for the segments, with limit (default 50, maximum 200) and offset (default 0).\n\nReply, as the catalogue words it: 200 a WalArchiveSummary (typed under Response Objects) for the summary. With list=1: 200 { count: integer, segments: WalSegment[] }, newest upload first.\n\nAuth: Session + view_system",
    "parameters": [
     {
      "name": "list",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "true"
       ]
      },
      "description": "Answer the segments and not the summary."
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 50,
       "maximum": 200
      },
      "description": "Read only with list. A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "offset",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 0
      },
      "description": "Read only with list."
     }
    ],
    "responses": {
     "200": {
      "description": "a WalArchiveSummary (typed under Response Objects) for the summary. With list=1: 200 { count: integer, segments: WalSegment[] }, newest upload first.",
      "content": {
       "application/json": {
        "schema": {
         "anyOf": [
          {
           "$ref": "#/components/schemas/WalArchiveSummary"
          },
          {
           "type": "object",
           "properties": {
            "count": {
             "type": "integer"
            },
            "segments": {
             "type": "array",
             "items": {
              "$ref": "#/components/schemas/WalSegment"
             }
            }
           }
          }
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_system",
    "x-permissions-named": [
     "view_system"
    ],
    "x-paging-parameters": [
     "limit",
     "offset"
    ],
    "x-typed-objects": [
     "WalArchiveSummary",
     "WalSegment"
    ],
    "x-catalogue-group": "backup-ops"
   }
  },
  "/compliance/reports/restore-request": {
   "post": {
    "operationId": "post_compliance_reports_restore_request",
    "tags": [
     "Backups, Restore & Release Channel"
    ],
    "summary": "Request a database restore. A different approver must approve it at /restore-approve.",
    "description": "Request, as the catalogue words it: { 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.\n\nReply, as the catalogue words it: 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.\n\nGuard: Repeat: a second request is refused with 409 until the first is approved, rejected or cancelled.\n\nAuth: Session + approve_system_updates",
    "requestBody": {
     "description": "{ 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.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "202": {
      "description": "{ status: 'pending_approval', request { mode: 'restore', target, requestedBy, requestedAt } }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "for a bad file name or a backup taken on a different PostgreSQL major version"
     },
     "403": {
      "description": "when you are the requester"
     },
     "404": {
      "description": "\"No request is awaiting approval.\""
     },
     "409": {
      "description": "\"A request is already awaiting approval.\" or \"An operation is already queued or in progress.\" | when the waiting request is not a restore, or is for a different target."
     },
     "503": {
      "description": "when the request could not be queued. Approve: 200 { approved: true, request }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_system_updates",
    "x-permissions-named": [
     "approve_system_updates"
    ],
    "x-catalogue-group": "backup-ops"
   }
  },
  "/compliance/reports/delete-request": {
   "post": {
    "operationId": "post_compliance_reports_delete_request",
    "tags": [
     "Backups, Restore & Release Channel"
    ],
    "summary": "Request deletion of a backup. A different approver must approve it at /delete-approve.",
    "description": "Request, as the catalogue words it: { target } required: zonitix_*.sql.gz or zonitix_*.tar.gz, with no path. /delete-approve takes { target? }.\n\nReply, as the catalogue words it: 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.\n\nGuard: 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.\n\nAuth: Session + approve_system_updates",
    "requestBody": {
     "description": "{ target } required: zonitix_*.sql.gz or zonitix_*.tar.gz, with no path. /delete-approve takes { target? }.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "202": {
      "description": "{ status: 'pending_approval', request { mode: 'delete-backup', target, requestedBy, requestedAt } }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "No further text in the catalogue."
     },
     "403": {
      "description": "when you are the requester"
     },
     "404": {
      "description": "No further text in the catalogue."
     },
     "409": {
      "description": "No further text in the catalogue. | ."
     },
     "503": {
      "description": "as for a restore request. Approve: 200 { approved: true, request, offbox { deletedObject, key, deleteFailed?, error? } }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_system_updates",
    "x-permissions-named": [
     "approve_system_updates"
    ],
    "x-catalogue-group": "backup-ops"
   }
  },
  "/compliance/reports/restore-drill-schedule": {
   "post": {
    "operationId": "post_compliance_reports_restore_drill_schedule",
    "tags": [
     "Backups, Restore & Release Channel"
    ],
    "summary": "Set the restore-drill schedule.",
    "description": "Request, as the catalogue words it: { enabled?, everyHours? }. A field you leave out keeps its stored value.\n\nReply, as the catalogue words it: 200 the saved { enabled, everyHours } together with the current restore-test status.\n\nGuard: Written to the audit log. Repeat: safe; the call sets values.\n\nAuth: Session + approve_system_updates",
    "requestBody": {
     "description": "{ enabled?, everyHours? }. A field you leave out keeps its stored value.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "the saved { enabled, everyHours } together with the current restore-test status.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_system_updates",
    "x-permissions-named": [
     "approve_system_updates"
    ],
    "x-catalogue-group": "backup-ops"
   }
  },
  "/compliance/reports/update-cancel": {
   "post": {
    "operationId": "post_compliance_reports_update_cancel",
    "tags": [
     "Backups, Restore & Release Channel"
    ],
    "summary": "Cancel a pending update request.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { cancelled: true, request } | 403 \"Only the administrator who requested this can cancel it\" | 404 \"No request is awaiting approval.\"\n\nGuard: No body. Repeat: the second call answers 404, because nothing is waiting any more.\n\nAuth: Session + request_system_updates | approve_system_updates",
    "responses": {
     "200": {
      "description": "{ cancelled: true, request }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "cancelled": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "request"
         ]
        }
       }
      }
     },
     "403": {
      "description": "\"Only the administrator who requested this can cancel it\""
     },
     "404": {
      "description": "\"No request is awaiting approval.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + request_system_updates | approve_system_updates",
    "x-permissions-named": [
     "approve_system_updates",
     "request_system_updates"
    ],
    "x-catalogue-group": "backup-ops"
   }
  },
  "/compliance/reports/release-channel": {
   "get": {
    "operationId": "get_compliance_reports_release_channel",
    "tags": [
     "Backups, Restore & Release Channel"
    ],
    "summary": "Read the release channel.",
    "description": "Reply, as the catalogue words it: 200 { channel: 'stable' | 'main', channels: string[], default: string, ... }.\n\nAuth: Session + view_system",
    "responses": {
     "200": {
      "description": "{ channel: 'stable' | 'main', channels: string[], default: string, ... }.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "channel": {
           "type": "string",
           "enum": [
            "stable",
            "main"
           ]
          },
          "channels": {
           "type": "array",
           "items": {
            "type": "string"
           }
          },
          "default": {
           "type": "string"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_system",
    "x-permissions-named": [
     "view_system"
    ],
    "x-catalogue-group": "backup-ops"
   },
   "put": {
    "operationId": "put_compliance_reports_release_channel",
    "tags": [
     "Backups, Restore & Release Channel"
    ],
    "summary": "Change the release channel.",
    "description": "Request, as the catalogue words it: { channel: 'stable' | 'main' }\n\nReply, as the catalogue words it: 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.\n\nGuard: Needs a fresh re-authentication. Repeat: safe; setting the channel it already has answers changed: false and writes no audit entry.\n\nAuth: Session + approve_system_updates + sudo",
    "requestBody": {
     "description": "{ channel: 'stable' | 'main' }",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "channel": {
          "type": "string",
          "enum": [
           "stable",
           "main"
          ]
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the object the GET answers plus changed: boolean and previous: string"
     },
     "400": {
      "description": "\"Release channel must be one of: stable, main.\""
     },
     "503": {
      "description": "when the update control directory is not writable."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + approve_system_updates + sudo",
    "x-permissions-named": [
     "approve_system_updates"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "backup-ops"
   }
  },
  "/compliance/data-export/jobs": {
   "get": {
    "operationId": "get_compliance_data_export_jobs",
    "tags": [
     "Company Data Export"
    ],
    "summary": "List the most recent export jobs, with the limits in force.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 { jobs: TenantExportJob[], limits: { maxPerDay: 3, artifactTtlHours: 24, linkTtlMinutes: 15 } } — TenantExportJob is typed under Response Objects; newest first, at most 20\n\nAuth: Session + administrator role",
    "responses": {
     "200": {
      "description": "{ jobs: TenantExportJob[], limits: { maxPerDay: 3, artifactTtlHours: 24, linkTtlMinutes: 15 } } — TenantExportJob is typed under Response Objects; newest first, at most 20",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "jobs": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/TenantExportJob"
           }
          },
          "limits": {
           "type": "object",
           "x-keys-not-described": [
            "maxPerDay",
            "artifactTtlHours",
            "linkTtlMinutes"
           ]
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + administrator role",
    "x-permissions-named": [],
    "x-administrator-role": true,
    "x-typed-objects": [
     "TenantExportJob"
    ],
    "x-catalogue-group": "company-data-export"
   },
   "post": {
    "operationId": "post_compliance_data_export_jobs",
    "tags": [
     "Company Data Export"
    ],
    "summary": "Start an export. It runs in the background.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nGuard: 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.\n\nAuth: Session + administrator role + sudo",
    "responses": {
     "202": {
      "description": "a TenantExportJob, newly created with status queued",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/TenantExportJob"
        }
       }
      }
     },
     "409": {
      "description": "code EXPORT_IN_PROGRESS when one is queued or running"
     },
     "429": {
      "description": "code EXPORT_RATE_LIMITED after 3 in 24 hours"
     },
     "503": {
      "description": "code NO_ENCRYPTION_KEY when the instance has no key to encrypt the file with"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + administrator role + sudo",
    "x-permissions-named": [],
    "x-step-up": "required",
    "x-administrator-role": true,
    "x-typed-objects": [
     "TenantExportJob"
    ],
    "x-catalogue-group": "company-data-export"
   }
  },
  "/compliance/data-export/jobs/{id}": {
   "get": {
    "operationId": "get_compliance_data_export_jobs_id",
    "tags": [
     "Company Data Export"
    ],
    "summary": "One job, for polling its progress.",
    "description": "Request, as the catalogue words it: No query parameters. :id is a whole number.\n\nReply, as the catalogue words it: 200 a TenantExportJob | 404 { error:'Export not found', code:null }\n\nAuth: Session + administrator role",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: TenantExportJob primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "a TenantExportJob",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/TenantExportJob"
        }
       }
      }
     },
     "404": {
      "description": "{ error:'Export not found', code:null }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + administrator role",
    "x-permissions-named": [],
    "x-administrator-role": true,
    "x-typed-objects": [
     "TenantExportJob"
    ],
    "x-catalogue-group": "company-data-export"
   }
  },
  "/compliance/data-export/jobs/{id}/link": {
   "post": {
    "operationId": "post_compliance_data_export_jobs_id_link",
    "tags": [
     "Company Data Export"
    ],
    "summary": "Mint a single-use download link for a ready export.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\n\nGuard: 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.\n\nAuth: Session + administrator role + sudo",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: TenantExportJob primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ token: string (64 hex characters), linkExpiresAt: string (date-time), path: string (/compliance/data-export/download/<token>) }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "token": {
           "type": "string",
           "description": "64 hex characters"
          },
          "linkExpiresAt": {
           "type": "string",
           "format": "date-time"
          },
          "path": {
           "type": "string",
           "description": "/compliance/data-export/download/<token>"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "'Export not found'"
     },
     "410": {
      "description": "code EXPORT_EXPIRED when the job is not ready or its file has expired"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + administrator role + sudo",
    "x-permissions-named": [],
    "x-step-up": "required",
    "x-administrator-role": true,
    "x-catalogue-group": "company-data-export"
   }
  },
  "/compliance/data-export/download/{token}": {
   "get": {
    "operationId": "get_compliance_data_export_download_token",
    "tags": [
     "Company Data Export"
    ],
    "summary": "Download the ZIP. The link is used up by the call.",
    "description": "Request, as the catalogue words it: No query parameters. :token is the 64 hex characters from the link call.\n\nReply, as the catalogue words it: 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\n\nGuard: 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.\n\nAuth: Session + administrator role",
    "parameters": [
     {
      "name": "token",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "the ZIP (application/zip, Content-Disposition attachment, Content-Length, X-Content-SHA256 = the SHA-256 of the ZIP, Cache-Control no-store)"
     },
     "410": {
      "description": "code LINK_USED_OR_EXPIRED when the link was already used, has expired or was minted by someone else | code EXPORT_EXPIRED when the file is no longer on the server"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + administrator role",
    "x-permissions-named": [],
    "x-administrator-role": true,
    "x-catalogue-group": "company-data-export"
   }
  },
  "/auth/security-lab/scan-schedule": {
   "get": {
    "operationId": "get_auth_security_lab_scan_schedule",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Read the recurring scan schedule.",
    "description": "Reply, as the catalogue words it: 200 a ScanSchedule (typed under Response Objects). nextDueAt is null when the schedule is off, has never run, or is already due.\n\nAuth: Session + view_security_findings | manage_compliance",
    "responses": {
     "200": {
      "description": "a ScanSchedule (typed under Response Objects). nextDueAt is null when the schedule is off, has never run, or is already due.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ScanSchedule"
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-typed-objects": [
     "ScanSchedule"
    ],
    "x-catalogue-group": "security-lab-more"
   },
   "post": {
    "operationId": "post_auth_security_lab_scan_schedule",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Set the recurring scan schedule.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 200 the same shape as the GET | 400 \"Provide at least one schedule field to update.\" or an interval outside 1 to 168.\n\nGuard: Audit-logged. Repeat: safe; only the fields you send are set.\n\nAuth: Session + manage_security_scopes | manage_compliance (licensed)",
    "requestBody": {
     "description": "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.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "the same shape as the GET"
     },
     "400": {
      "description": "\"Provide at least one schedule field to update.\" or an interval outside 1 to 168."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_scopes | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_scopes"
    ],
    "x-licensed": true,
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/probe-catalog": {
   "get": {
    "operationId": "get_auth_security_lab_probe_catalog",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "The probes the Lab can run.",
    "description": "Reply, as the catalogue words it: 200 { probes[{ id, category, intensity, remoteOk, localOk, heavy, pathBound, endpoint, cwe[], budgetMs }] }\n\nGuard: The whole catalogue in one answer; no paging.\n\nAuth: Session + view_security_findings | manage_compliance",
    "responses": {
     "200": {
      "description": "{ probes[{ id, category, intensity, remoteOk, localOk, heavy, pathBound, endpoint, cwe[], budgetMs }] }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/scans/active": {
   "get": {
    "operationId": "get_auth_security_lab_scans_active",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "The scan that is queued or running now, if any.",
    "description": "Reply, as the catalogue words it: 200 { scans[] }: empty, or the one live scan with currentProbe and stopping.\n\nAuth: Session + view_security_findings | manage_compliance",
    "responses": {
     "200": {
      "description": "{ scans[] }: empty, or the one live scan with currentProbe and stopping.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "scans": {
           "type": "array"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/scans/{id}/verify": {
   "get": {
    "operationId": "get_auth_security_lab_scans_id_verify",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Re-check a finished scan's sealed report against its stored hash and audit-log entry.",
    "description": "Reply, as the catalogue words it: 200 the verification result when the scan has a sealed report | 404 with the same result object (sealed: false) when it has none.\n\nAuth: Session + view_security_findings | manage_compliance",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityScan primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "the verification result when the scan has a sealed report"
     },
     "404": {
      "description": "with the same result object (sealed: false) when it has none."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/trends": {
   "get": {
    "operationId": "get_auth_security_lab_trends",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Posture over time (the Trends view).",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + view_security_findings | manage_compliance",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/scopes/{id}": {
   "patch": {
    "operationId": "patch_auth_security_lab_scopes_id",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: { 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).\n\nReply, as the catalogue words it: 200 the scope | 401 { error: 'SUDO_REQUIRED', message } without a fresh step-up | 404 \"Scope not found\".\n\nAuth: Session + manage_security_scopes | manage_compliance + sudo",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityScope primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "{ 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).",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "the scope"
     },
     "401": {
      "description": "{ error: 'SUDO_REQUIRED', message } without a fresh step-up",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "\"Scope not found\"."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_scopes | manage_compliance + sudo",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_scopes"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "security-lab-more"
   },
   "delete": {
    "operationId": "delete_auth_security_lab_scopes_id",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Disable a scan scope.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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\".\n\nGuard: Needs a fresh step-up; written to the audit log as SECURITY_SCOPE_DISABLED.\n\nAuth: Session + manage_security_scopes | manage_compliance + sudo",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityScope primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "the scope"
     },
     "401": {
      "description": "{ error: 'SUDO_REQUIRED', message } without a fresh step-up (this route passes the status through, so it stays 401)",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "\"Scope not found\"."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_scopes | manage_compliance + sudo",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_scopes"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/scopes/{id}/verify": {
   "post": {
    "operationId": "post_auth_security_lab_scopes_id_verify",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Run the ownership check for a scope's hosts.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { ok: true, verifiedHosts, unprovenHosts, ... } | 400 with the same result object when ok is false.\n\nAuth: Session + manage_security_scopes | manage_compliance",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityScope primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ ok: true, verifiedHosts, unprovenHosts, ... }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "verifiedHosts",
          "unprovenHosts"
         ]
        }
       }
      }
     },
     "400": {
      "description": "with the same result object when ok is false."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_scopes | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_scopes"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/issues": {
   "get": {
    "operationId": "get_auth_security_lab_issues",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Findings grouped into issues (totals at /issues/summary, one at /issues/:id).",
    "description": "Request, as the catalogue words it: 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).\n\nReply, as the catalogue words it: 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\".\n\nAuth: Session + view_security_findings | manage_compliance",
    "parameters": [
     {
      "name": "severity",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "status",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "kev",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "target",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "search",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "includeArchived",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "true"
       ]
      }
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 50,
       "maximum": 200
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "offset",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 0,
       "minimum": 0
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "sort",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "dir",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "asc sorts ascending; any other value is descending."
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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 }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "issues": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/SecurityIssue"
           }
          },
          "total": {
           "type": "integer"
          },
          "limit": {
           "type": "integer"
          },
          "offset": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "\"Issue not found\"."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-paging-parameters": [
     "limit",
     "offset"
    ],
    "x-typed-objects": [
     "SecurityIssue"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/issues/summary": {
   "get": {
    "operationId": "get_auth_security_lab_issues_summary",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Issue totals for the same filters as GET /auth/security-lab/issues.",
    "description": "Request, as the catalogue words it: Query: the filters of GET /auth/security-lab/issues (severity, status, kev, target, search, includeArchived).\n\nReply, as the catalogue words it: 200 { total: integer, kevOpen: integer, bySeverity: { [severity]: integer } }. One object, not a list.\n\nAuth: Session + view_security_findings | manage_compliance",
    "responses": {
     "200": {
      "description": "{ total: integer, kevOpen: integer, bySeverity: { [severity]: integer } }. One object, not a list.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "total": {
           "type": "integer"
          },
          "kevOpen": {
           "type": "integer"
          },
          "bySeverity": {
           "type": "object",
           "additionalProperties": {
            "type": "integer"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/issues/{id}": {
   "get": {
    "operationId": "get_auth_security_lab_issues_id",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "One issue.",
    "description": "Reply, as the catalogue words it: 200 { issue: SecurityIssue } (typed under Response Objects) | 404 \"Issue not found\".\n\nAuth: Session + view_security_findings | manage_compliance",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityIssue primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ issue: SecurityIssue } (typed under Response Objects)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "issue": {
           "$ref": "#/components/schemas/SecurityIssue"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "\"Issue not found\"."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-typed-objects": [
     "SecurityIssue"
    ],
    "x-catalogue-group": "security-lab-more"
   },
   "patch": {
    "operationId": "patch_auth_security_lab_issues_id",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Update an issue.",
    "description": "Request, as the catalogue words it: Any of { status (open | acknowledged | in_remediation | retest_pending | risk_accepted | false_positive), statusNote, owner, ticketRef, acceptedUntil }\n\nReply, as the catalogue words it: 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.\n\nGuard: status: risk_accepted opens a risk exception under the same rules as POST /findings/:id/exception.\n\nAuth: Session + manage_security_findings | manage_compliance",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityIssue primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "Any of { status (open | acknowledged | in_remediation | retest_pending | risk_accepted | false_positive), statusNote, owner, ticketRef, acceptedUntil }",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "the issue; when the change opened a risk exception, exception { id, status, acceptedUntil, requestedBy } is added",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "\"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 | EXPIRY_REQUIRED when accepting risk without a future acceptedUntil"
     },
     "404": {
      "description": "\"Issue not found\""
     },
     "409": {
      "description": "EXCEPTION_PENDING when a risk acceptance for it is already waiting for a second approver."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/findings/{id}/issues": {
   "get": {
    "operationId": "get_auth_security_lab_findings_id_issues",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "The issues one finding was grouped into.",
    "description": "Reply, as the catalogue words it: 200 { findingId, issues: SecurityIssue[], total: integer } | 404 \"Finding not found\". Everything in one answer; no paging parameters are read.\n\nAuth: Session + view_security_findings | manage_compliance",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityFinding primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ findingId, issues: SecurityIssue[], total: integer }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "issues": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/SecurityIssue"
           }
          },
          "total": {
           "type": "integer"
          }
         },
         "x-keys-not-described": [
          "findingId"
         ]
        }
       }
      }
     },
     "404": {
      "description": "\"Finding not found\". Everything in one answer; no paging parameters are read."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-typed-objects": [
     "SecurityIssue"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/findings/{id}/exception": {
   "post": {
    "operationId": "post_auth_security_lab_findings_id_exception",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: A SecurityExceptionRequest: { justification (required), acceptedUntil (required: a date in the future) }\n\nReply, as the catalogue words it: 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.\n\nGuard: Repeat: 409 code EXCEPTION_EXISTS; a finding with a pending or active exception cannot get a second one until that one is revoked\n\nAuth: Session + manage_security_findings | manage_compliance (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityFinding primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A SecurityExceptionRequest: { justification (required), acceptedUntil (required: a date in the future) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/SecurityExceptionRequest"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "EXPIRY_REQUIRED (missing, not a date, or not in the future)"
     },
     "404": {
      "description": "\"Finding not found.\""
     },
     "409": {
      "description": "EXCEPTION_EXISTS. The record also carries expiredAt, revokedAt and revokedBy, which are unset on a new exception."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_findings | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "x-licensed": true,
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/exceptions": {
   "get": {
    "operationId": "get_auth_security_lab_exceptions",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "List risk exceptions (one finding's full timeline at /findings/:id/exceptions).",
    "description": "Request, as the catalogue words it: Query findingId?, issueId?, includeArchived? (1 | true: include revoked ones).\n\nReply, as the catalogue words it: 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.\n\nAuth: Session + view_security_findings | manage_compliance",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "exceptions": {
           "type": "array"
          },
          "ungoverned": {
           "type": [
            "object",
            "null"
           ],
           "properties": {
            "issues": {
             "type": "integer"
            },
            "findings": {
             "type": "integer"
            }
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/findings/{id}/exceptions": {
   "get": {
    "operationId": "get_auth_security_lab_findings_id_exceptions",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Every risk exception ever opened for one finding (its timeline).",
    "description": "Reply, as the catalogue words it: 200 { exceptions[] }. Everything in one answer; no paging parameters are read.\n\nAuth: Session + view_security_findings | manage_compliance",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityFinding primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ exceptions[] }. Everything in one answer; no paging parameters are read.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "exceptions": {
           "type": "array"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/exceptions/{id}/approve": {
   "post": {
    "operationId": "post_auth_security_lab_exceptions_id_approve",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Approve a pending Critical or High exception; 403 SOD_SELF_APPROVAL if you requested it. Revoke at /exceptions/:id/revoke: the finding reopens.",
    "description": "Request, as the catalogue words it: No body. Revoke takes { note? }\n\nReply, as the catalogue words it: 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.\n\nGuard: Repeat: 409; once approved, the exception is no longer pending\n\nAuth: Session + manage_security_findings | manage_compliance (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityException primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "the exception, for both calls. Errors are { error: string, code?: string }: 404 \"Exception not found.\"",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "409": {
      "description": "when the exception is not pending (approve) or not active or pending (revoke) | approve only: 403 SOD_SELF_APPROVAL | SUBJECT_NOT_OPEN when the finding or issue is already verified fixed or marked a false positive."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_findings | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "x-licensed": true,
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/exceptions/{id}/revoke": {
   "post": {
    "operationId": "post_auth_security_lab_exceptions_id_revoke",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Revoke a risk exception; the finding reopens.",
    "description": "Request, as the catalogue words it: A SecurityExceptionRevoke: { note? }\n\nReply, as the catalogue words it: 200 the exception | 404 \"Exception not found.\" | 409 when the exception is not active or pending. Errors are { error: string, code?: string }.\n\nGuard: Repeat: 409; a revoked exception is neither active nor pending\n\nAuth: Session + manage_security_findings | manage_compliance (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityException primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "A SecurityExceptionRevoke: { note? }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/SecurityExceptionRevoke"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the exception"
     },
     "404": {
      "description": "\"Exception not found.\""
     },
     "409": {
      "description": "when the exception is not active or pending. Errors are { error: string, code?: string }.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_findings | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "x-licensed": true,
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/findings/archive": {
   "post": {
    "operationId": "post_auth_security_lab_findings_archive",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: A FindingsArchive: { scope? } (or ?scope=): all (default), remote or local\n\nReply, as the catalogue words it: 200 { archived, archivedIssues, scope } | 400 for any other scope value | 401 { error: 'SUDO_REQUIRED', message } without a fresh step-up.\n\nAuth: Session + manage_security_findings | manage_compliance + sudo",
    "requestBody": {
     "description": "A FindingsArchive: { scope? } (or ?scope=): all (default), remote or local",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/FindingsArchive"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ archived, archivedIssues, scope }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "for any other scope value"
     },
     "401": {
      "description": "{ error: 'SUDO_REQUIRED', message } without a fresh step-up.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_findings | manage_compliance + sudo",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "x-step-up": "required",
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/findings": {
   "delete": {
    "operationId": "delete_auth_security_lab_findings",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Retired: always answers 410 Gone. Use POST /findings/archive.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 410 { error: \"Clearing findings was replaced by archiving (POST /security-lab/findings/archive) so lifecycle evidence is kept.\" }. Nothing is changed.\n\nGuard: Repeat: every call answers the same 410 and changes nothing\n\nAuth: Session + manage_security_findings | manage_compliance",
    "responses": {
     "410": {
      "description": "{ error: \"Clearing findings was replaced by archiving (POST /security-lab/findings/archive) so lifecycle evidence is kept.\" }. Nothing is changed.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/host-scan": {
   "post": {
    "operationId": "post_auth_security_lab_host_scan",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Match the software inventory of your enrolled devices against the CVE database. Writes host findings; audit-logged.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 a summary: ok, devices, scanned, vulnerable, totalCves, kevHits.\n\nGuard: Repeat: each call scans the fleet again and writes another SECURITY_HOST_SCAN audit entry\n\nAuth: Session + manage_security_findings | manage_compliance (licensed)",
    "responses": {
     "200": {
      "description": "a summary: ok, devices, scanned, vulnerable, totalCves, kevHits."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_findings | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "x-licensed": true,
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/sbom-scan": {
   "post": {
    "operationId": "post_auth_security_lab_sbom_scan",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Scan an uploaded software bill of materials.",
    "description": "Request, as the catalogue words it: { sbom } as a JSON string or object in CycloneDX or SPDX form, or the document itself as the whole body.\n\nReply, as the catalogue words it: 200 { label, kind, components, cveCount, severity, kevHits, ... } | 400 with the reason when no document was sent or it could not be read.\n\nAuth: Session + manage_security_findings | manage_compliance (licensed)",
    "requestBody": {
     "description": "{ sbom } as a JSON string or object in CycloneDX or SPDX form, or the document itself as the whole body.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ label, kind, components, cveCount, severity, kevHits, ... }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "with the reason when no document was sent or it could not be read."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_findings | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "x-licensed": true,
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/cve-intel": {
   "get": {
    "operationId": "get_auth_security_lab_cve_intel",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Vulnerability intelligence status (refresh at /cve-intel/refresh).",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + view_security_findings | manage_compliance",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "total": {
           "type": "integer"
          },
          "kevListed": {
           "type": "integer"
          }
         },
         "x-keys-not-described": [
          "lastRefreshedAt"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/cve-intel/refresh": {
   "post": {
    "operationId": "post_auth_security_lab_cve_intel_refresh",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Refresh the vulnerability intelligence now. The call waits for the refresh to finish.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 a refresh summary object: ok, plus kev, epss and enrichment, one entry per source refreshed.\n\nGuard: Audit-logged as SECURITY_CVE_INTEL_REFRESH, at warning level when the summary's ok is false.\n\nAuth: Session + manage_security_findings | manage_compliance (licensed)",
    "responses": {
     "200": {
      "description": "a refresh summary object: ok, plus kev, epss and enrichment, one entry per source refreshed."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_findings | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "x-licensed": true,
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/intel-status": {
   "get": {
    "operationId": "get_auth_security_lab_intel_status",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Freshness of the vulnerability intelligence (what the Intel fresh / Intel stale chip shows).",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + view_security_findings | manage_compliance",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "source": {
           "type": "string"
          },
          "ageHours": {
           "type": [
            "number",
            "null"
           ]
          },
          "staleHours": {
           "type": "integer"
          },
          "stale": {
           "type": "boolean"
          },
          "total": {
           "type": "integer"
          },
          "kevCount": {
           "type": "integer"
          },
          "epssCount": {
           "type": "integer"
          },
          "trivyDbVersion": {
           "type": [
            "string",
            "null"
           ]
          },
          "trivyDbAgeHours": {
           "type": [
            "number",
            "null"
           ]
          },
          "dbAgeDays": {
           "type": [
            "integer",
            "null"
           ]
          },
          "fresh": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "stampedAt",
          "importedAt",
          "trivyDbAt",
          "dbUpdatedAt",
          "kevUpdatedAt",
          "epssUpdatedAt"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/intel-import": {
   "post": {
    "operationId": "post_auth_security_lab_intel_import",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Load a vulnerability-intelligence bundle on an instance that cannot reach the feeds.",
    "description": "Request, as the catalogue words it: { bundle }: the bundle as an object or string, or { bundle: { path } } naming a file on the server.\n\nReply, as the catalogue words it: 200 { kevImported, epssImported, asOf, signatureVerified, enrichment, ... } | 400 with the reason when no bundle was sent or its check failed.\n\nGuard: A bundle that fails validation, its checksum or its signature imports nothing; the rejection is written to the audit log.\n\nAuth: Session + manage_security_findings | manage_compliance (licensed)",
    "requestBody": {
     "description": "{ bundle }: the bundle as an object or string, or { bundle: { path } } naming a file on the server.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ kevImported, epssImported, asOf, signatureVerified, enrichment, ... }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "with the reason when no bundle was sent or its check failed."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_findings | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "x-licensed": true,
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/analyst-summary": {
   "get": {
    "operationId": "get_auth_security_lab_analyst_summary",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "The AI analyst summary on the Findings view: figures computed by code, narrative drafted by the local model.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + view_security_findings | manage_compliance",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "bundle": {
           "type": "object",
           "properties": {
            "unit": {
             "type": "string",
             "enum": [
              "issue",
              "finding"
             ]
            },
            "posture": {
             "type": [
              "number",
              "null"
             ]
            },
            "totalCount": {
             "type": "integer"
            },
            "openCount": {
             "type": "integer"
            },
            "acceptedCount": {
             "type": "integer"
            },
            "countsBySeverity": {
             "type": "object",
             "additionalProperties": {
              "type": "integer"
             }
            },
            "kevCount": {
             "type": "integer"
            },
            "kev": {
             "type": "array",
             "items": {
              "type": "object",
              "properties": {
               "cveId": {
                "type": [
                 "string",
                 "null"
                ]
               },
               "severity": {
                "type": "string"
               },
               "target": {
                "type": "string"
               },
               "pkgName": {
                "type": [
                 "string",
                 "null"
                ]
               },
               "installedVersion": {
                "type": [
                 "string",
                 "null"
                ]
               },
               "fixedVersion": {
                "type": [
                 "string",
                 "null"
                ]
               },
               "epssScore": {
                "type": [
                 "number",
                 "null"
                ]
               }
              }
             }
            },
            "slaBreachCount": {
             "type": "integer"
            },
            "slaBreaches": {
             "type": "array",
             "items": {
              "type": "object",
              "properties": {
               "ref": {
                "type": "string"
               },
               "severity": {
                "type": "string"
               },
               "target": {
                "type": "string"
               },
               "overdueDays": {
                "type": "integer"
               }
              },
              "x-keys-not-described": [
               "slaDueAt"
              ]
             }
            },
            "topRiskAssets": {
             "type": "array",
             "items": {
              "type": "object",
              "properties": {
               "target": {
                "type": "string"
               },
               "issueCount": {
                "type": "integer"
               },
               "kevCount": {
                "type": "integer"
               },
               "maxSeverity": {
                "type": "string"
               },
               "riskScore": {
                "type": "integer"
               }
              }
             }
            },
            "toxicComboCount": {
             "type": "integer"
            },
            "toxicCombos": {
             "type": "array",
             "items": {
              "type": "object",
              "properties": {
               "label": {
                "type": "string"
               },
               "severity": {
                "type": "string"
               },
               "hops": {
                "type": [
                 "integer",
                 "null"
                ]
               }
              }
             }
            }
           },
           "x-keys-not-described": [
            "generatedAt"
           ]
          },
          "prose": {
           "type": [
            "string",
            "null"
           ]
          },
          "provenance": {
           "type": "object",
           "properties": {
            "aiUsed": {
             "type": "boolean"
            },
            "factCount": {
             "type": "integer"
            },
            "model": {
             "type": [
              "string",
              "null"
             ]
            },
            "stamp": {
             "type": [
              "string",
              "null"
             ]
            },
            "reason": {
             "type": [
              "string",
              "null"
             ]
            }
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/compliance-evidence": {
   "get": {
    "operationId": "get_auth_security_lab_compliance_evidence",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Scan-cadence evidence per scope, as passed to the Compliance Hub.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + view_security_findings | manage_compliance",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "available": {
           "type": "boolean"
          },
          "controls": {
           "type": "array",
           "items": {
            "type": "object"
           }
          },
          "scopes": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "scope": {
              "type": "string"
             },
             "hosts": {
              "type": "array",
              "items": {
               "type": "string"
              }
             },
             "verified": {
              "type": "boolean"
             },
             "controls": {
              "type": "array",
              "items": {
               "type": "string"
              }
             },
             "cadence": {
              "type": "object",
              "properties": {
               "withinWindow": {
                "type": "boolean"
               },
               "daysSinceLastScan": {
                "type": [
                 "integer",
                 "null"
                ]
               },
               "overdue": {
                "type": "boolean"
               },
               "lastExternalScan": {
                "type": [
                 "object",
                 "null"
                ],
                "properties": {
                 "scanId": {
                  "type": "string"
                 },
                 "status": {
                  "type": "string"
                 },
                 "kevOpen": {
                  "type": "integer"
                 },
                 "snapshotHash": {
                  "type": [
                   "string",
                   "null"
                  ]
                 },
                 "snapshotSealed": {
                  "type": "boolean"
                 },
                 "controls": {
                  "type": "array",
                  "items": {
                   "type": "string"
                  }
                 }
                },
                "x-keys-not-described": [
                 "timestamp"
                ]
               }
              },
              "x-keys-not-described": [
               "dueBy"
              ]
             }
            }
           }
          },
          "summary": {
           "type": [
            "object",
            "null"
           ],
           "properties": {
            "verifiedScopes": {
             "type": "integer"
            },
            "scopesScannedInWindow": {
             "type": "integer"
            },
            "scopesOverdue": {
             "type": "integer"
            },
            "totalExternalScans": {
             "type": "integer"
            }
           }
          }
         },
         "x-keys-not-described": [
          "generatedAt",
          "pciWindowDays"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/kev-exposure": {
   "get": {
    "operationId": "get_auth_security_lab_kev_exposure",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Exposure to known-exploited vulnerabilities.",
    "description": "Request, as the catalogue words it: Query limit (default 100, maximum 200), offset (default 0), includeArchived (1 | true), verdict (exposed | patched | unconfirmed).\n\nReply, as the catalogue words it: 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.\n\nGuard: 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.\n\nAuth: Session + view_security_findings | manage_compliance",
    "parameters": [
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 100,
       "maximum": 200
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "offset",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 0,
       "minimum": 0
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "includeArchived",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "true"
       ]
      }
     },
     {
      "name": "verdict",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "exposed",
        "patched",
        "unconfirmed"
       ]
      }
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "items": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "id": {
              "type": "string",
              "format": "uuid"
             },
             "findingId": {
              "type": "string",
              "format": "uuid"
             },
             "cveId": {
              "type": [
               "string",
               "null"
              ]
             },
             "title": {
              "type": [
               "string",
               "null"
              ]
             },
             "target": {
              "type": "string"
             },
             "pkgName": {
              "type": [
               "string",
               "null"
              ]
             },
             "installedVersion": {
              "type": [
               "string",
               "null"
              ]
             },
             "fixedVersion": {
              "type": [
               "string",
               "null"
              ]
             },
             "severity": {
              "type": "string"
             },
             "cvssScore": {
              "type": [
               "number",
               "null"
              ]
             },
             "epssScore": {
              "type": [
               "number",
               "null"
              ]
             },
             "status": {
              "type": "string"
             },
             "kevListed": {
              "type": "boolean"
             },
             "confirmed": {
              "type": "boolean"
             },
             "confirmationVerdict": {
              "type": "string",
              "enum": [
               "exposed",
               "patched",
               "unconfirmed"
              ]
             },
             "confirmationLabel": {
              "type": "string"
             }
            },
            "x-keys-not-described": [
             "confirmationMethod"
            ]
           }
          },
          "total": {
           "type": "integer"
          },
          "limit": {
           "type": "integer"
          },
          "offset": {
           "type": "integer"
          },
          "counts": {
           "type": [
            "object",
            "null"
           ],
           "properties": {
            "total": {
             "type": "integer"
            },
            "exposed": {
             "type": "integer"
            },
            "patched": {
             "type": "integer"
            },
            "unconfirmed": {
             "type": "integer"
            },
            "confirmedExposed": {
             "type": "integer"
            }
           }
          }
         },
         "x-keys-not-described": [
          "method"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-paging-parameters": [
     "limit",
     "offset"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/attack-paths": {
   "get": {
    "operationId": "get_auth_security_lab_attack_paths",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Attack paths across your assets (one at /attack-paths/:id).",
    "description": "Request, as the catalogue words it: Query ?includeArchived=true\n\nReply, as the catalogue words it: 200 { items[], total, counts { toxic, inconclusive } }, highest CVSS first; no paging. /attack-paths/:id adds chain[], issues[] and mitigation | 404 \"Attack path not found\".\n\nAuth: Session + view_security_findings | manage_compliance",
    "responses": {
     "200": {
      "description": "{ items[], total, counts { toxic, inconclusive } }, highest CVSS first; no paging. /attack-paths/:id adds chain[], issues[] and mitigation",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "items": {
           "type": "array"
          }
         },
         "x-keys-not-described": [
          "total",
          "counts"
         ]
        }
       }
      }
     },
     "404": {
      "description": "\"Attack path not found\"."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/attack-paths/{id}": {
   "get": {
    "operationId": "get_auth_security_lab_attack_paths_id",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "One attack path in full.",
    "description": "Reply, as the catalogue words it: 200 the attack path with chain[], issues[] and mitigation | 404 \"Attack path not found\".\n\nAuth: Session + view_security_findings | manage_compliance",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "the attack path with chain[], issues[] and mitigation"
     },
     "404": {
      "description": "\"Attack path not found\"."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/unified-assets": {
   "get": {
    "operationId": "get_auth_security_lab_unified_assets",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "The combined asset inventory (one at /unified-assets/:id).",
    "description": "Reply, as the catalogue words it: 200 { assets[], total }, ranked by composite risk; everything in one answer, no paging. /unified-assets/:id answers the asset | 404 \"Asset not found\".\n\nAuth: Session + view_security_findings | manage_compliance",
    "responses": {
     "200": {
      "description": "{ assets[], total }, ranked by composite risk; everything in one answer, no paging. /unified-assets/:id answers the asset",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "assets": {
           "type": "array"
          }
         },
         "x-keys-not-described": [
          "total"
         ]
        }
       }
      }
     },
     "404": {
      "description": "\"Asset not found\"."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/unified-assets/{id}": {
   "get": {
    "operationId": "get_auth_security_lab_unified_assets_id",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "One asset from the combined inventory.",
    "description": "Reply, as the catalogue words it: 200 the asset | 404 \"Asset not found\".\n\nAuth: Session + view_security_findings | manage_compliance",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "the asset"
     },
     "404": {
      "description": "\"Asset not found\"."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/assets/discover": {
   "post": {
    "operationId": "post_auth_security_lab_assets_discover",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Run hostname discovery under your verified domains now (the Discover now button). One discovered asset at GET /assets/:id.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { ok: true, ... } with the counts of what the run found.\n\nGuard: Discovery only adds to the inventory; it never authorises a scan. Repeat: each call runs discovery again\n\nAuth: Session + manage_security_scopes | manage_compliance (licensed)",
    "responses": {
     "200": {
      "description": "{ ok: true, ... } with the counts of what the run found.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_scopes | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_scopes"
    ],
    "x-licensed": true,
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/assets/{id}": {
   "get": {
    "operationId": "get_auth_security_lab_assets_id",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "One discovered asset.",
    "description": "Reply, as the catalogue words it: 200 the asset | 404 \"Asset not found\".\n\nAuth: Session + view_security_findings | manage_compliance",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityAsset primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "the asset"
     },
     "404": {
      "description": "\"Asset not found\"."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/assets/{id}/criticality": {
   "patch": {
    "operationId": "patch_auth_security_lab_assets_id_criticality",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Set how critical an asset is; it feeds the risk score. Audit-logged.",
    "description": "Request, as the catalogue words it: An AssetCriticalityUpdate: { criticality: 'low' | 'medium' | 'high' | 'crit' } ('critical' is accepted and stored as 'crit')\n\nReply, as the catalogue words it: 200 { id, name, criticality } | 400 for any other value | 404 \"Asset not found\".\n\nGuard: Repeat: safe; the call sets a value.\n\nAuth: Session + manage_security_scopes | manage_compliance",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityAsset primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "An AssetCriticalityUpdate: { criticality: 'low' | 'medium' | 'high' | 'crit' } ('critical' is accepted and stored as 'crit')",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AssetCriticalityUpdate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ id, name, criticality }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "for any other value"
     },
     "404": {
      "description": "\"Asset not found\"."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_scopes | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_scopes"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/remediations": {
   "get": {
    "operationId": "get_auth_security_lab_remediations",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Remediation work items.",
    "description": "Request, as the catalogue words it: Query status?, findingId?, target?, limit (default 100, maximum 500), offset (default 0).\n\nReply, as the catalogue words it: 200 { remediations: SecurityRemediation[], total: integer, limit: integer, offset: integer } (typed under Response Objects), newest first.\n\nAuth: Session + view_security_findings | manage_compliance",
    "parameters": [
     {
      "name": "status",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "findingId",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "target",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 100,
       "maximum": 500
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "offset",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 0,
       "minimum": 0
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     }
    ],
    "responses": {
     "200": {
      "description": "{ remediations: SecurityRemediation[], total: integer, limit: integer, offset: integer } (typed under Response Objects), newest first.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "remediations": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/SecurityRemediation"
           }
          },
          "total": {
           "type": "integer"
          },
          "limit": {
           "type": "integer"
          },
          "offset": {
           "type": "integer"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-paging-parameters": [
     "limit",
     "offset"
    ],
    "x-typed-objects": [
     "SecurityRemediation"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/findings/{id}/remediation": {
   "post": {
    "operationId": "post_auth_security_lab_findings_id_remediation",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Open a remediation for a finding (hand off at /remediations/:id/handoff, retest at /remediations/retest).",
    "description": "Request, as the catalogue words it: Propose: none. Handoff: { handoffRef? (up to 255 characters) }\n\nReply, as the catalogue words it: 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, ... }.\n\nGuard: Repeat: a finding that already has an open remediation gets that one back with 200 (reason 'already_open'), not a second one.\n\nAuth: Session + manage_security_findings | manage_compliance (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Typed from the primary key of the record it names: SecurityFinding primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "Propose: none. Handoff: { handoffRef? (up to 255 characters) }",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ proposed: false, reason, remediation? } when nothing new was opened",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "proposed": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "reason",
          "remediation"
         ]
        }
       }
      }
     },
     "201": {
      "description": "{ proposed: true, reason: 'proposed', remediation }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "proposed": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "reason",
          "remediation"
         ]
        }
       }
      }
     },
     "400": {
      "description": "\"Cannot hand off\" with code"
     },
     "404": {
      "description": "\"Finding not found\". Handoff: 200 the remediation | . Retest: 200 { ok: true, ... }.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_findings | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "x-licensed": true,
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/remediations/{id}/handoff": {
   "post": {
    "operationId": "post_auth_security_lab_remediations_id_handoff",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Mark a remediation as handed off, with an optional reference to where the work is tracked.",
    "description": "Request, as the catalogue words it: A RemediationHandoff: { handoffRef? (up to 255 characters) }\n\nReply, as the catalogue words it: 200 the SecurityRemediation | 400 \"Cannot hand off (<reason>).\" with the reason as code | 404 \"Remediation not found\".\n\nAuth: Session + manage_security_findings | manage_compliance (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "requestBody": {
     "description": "A RemediationHandoff: { handoffRef? (up to 255 characters) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/RemediationHandoff"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the SecurityRemediation",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/SecurityRemediation"
        }
       }
      }
     },
     "400": {
      "description": "\"Cannot hand off (<reason>).\" with the reason as code"
     },
     "404": {
      "description": "\"Remediation not found\"."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_findings | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "SecurityRemediation"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/remediations/retest": {
   "post": {
    "operationId": "post_auth_security_lab_remediations_retest",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Re-test the remediations that are waiting for a re-test, now.",
    "description": "Request, as the catalogue words it: No body is read.\n\nReply, as the catalogue words it: 200 { ok: true, ... } with the counts of the sweep.\n\nAuth: Session + manage_security_findings | manage_compliance (licensed)",
    "responses": {
     "200": {
      "description": "{ ok: true, ... } with the counts of the sweep.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_findings | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "x-licensed": true,
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/auth/security-lab/ticketing": {
   "get": {
    "operationId": "get_auth_security_lab_ticketing",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Read the ticketing integration settings (POST to change them: manage_security_scopes | manage_compliance).",
    "description": "Request, as the catalogue words it: POST: { providerId?, projectKey? }. An empty providerId switches automatic ticketing off.\n\nReply, as the catalogue words it: 200 { providerId, projectKey, providers[{ id, name, type, isActive }] }. POST 200 { ok: true }.\n\nGuard: Repeat: safe; POST sets both values.\n\nAuth: Session + view_security_findings | manage_compliance",
    "responses": {
     "200": {
      "description": "{ providerId, projectKey, providers[{ id, name, type, isActive }] }. POST 200 { ok: true }.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_security_findings | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "view_security_findings"
    ],
    "x-catalogue-group": "security-lab-more"
   },
   "post": {
    "operationId": "post_auth_security_lab_ticketing",
    "tags": [
     "Security Lab: Schedules, Issues, Exceptions & Attack Paths"
    ],
    "summary": "Change the ticketing integration settings.",
    "description": "Request, as the catalogue words it: A TicketingUpdate: { providerId?, projectKey? }. An empty providerId switches automatic ticketing off.\n\nReply, as the catalogue words it: 200 { ok: true }.\n\nGuard: Repeat: safe; the call sets both values.\n\nAuth: Session + manage_security_scopes | manage_compliance",
    "requestBody": {
     "description": "A TicketingUpdate: { providerId?, projectKey? }. An empty providerId switches automatic ticketing off.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/TicketingUpdate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok: true }.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_security_scopes | manage_compliance",
    "x-permissions-named": [
     "manage_compliance",
     "manage_security_scopes"
    ],
    "x-catalogue-group": "security-lab-more"
   }
  },
  "/user-management/employees": {
   "get": {
    "operationId": "get_user_management_employees",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "The directory of employee files (one file at /employees/:userId).",
    "description": "Request, as the catalogue words it: 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).\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Date of birth and home address are never in a list or a file: only hasDateOfBirth and hasHomeAddress.\n\nAuth: Session + view_employee_records | manage_employee_records",
    "parameters": [
     {
      "name": "search",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "status",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "department",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 50,
       "minimum": 1,
       "maximum": 200
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     },
     {
      "name": "offset",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "default": 0,
       "minimum": 0
      },
      "description": "A value outside the stated range is moved to the nearest bound, not refused."
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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 }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "total": {
           "type": "integer",
           "description": "every match, not the page"
          },
          "employees": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/EmployeeListItem"
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"Invalid employee id.\""
     },
     "404": {
      "description": "\"Employee not found.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_employee_records | manage_employee_records",
    "x-permissions-named": [
     "manage_employee_records",
     "view_employee_records"
    ],
    "x-paging-parameters": [
     "limit",
     "offset"
    ],
    "x-typed-objects": [
     "EmployeeListItem",
     "EmployeeReminder",
     "EmployeeFile",
     "EmployeeFileUser",
     "EmployeeProfile",
     "EmergencyContact",
     "EmploymentEvent",
     "HrAuthority"
    ],
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/{userId}": {
   "put": {
    "operationId": "put_user_management_employees_userId",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "Edit a file's employment and personal details.",
    "description": "Request, as the catalogue words it: 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).\n\nReply, as the catalogue words it: 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.\"\n\nGuard: 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.\n\nAuth: Session + manage_employee_records",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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).",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/EmployeeProfileUpdate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the EmployeeFile object",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/EmployeeFile"
        }
       }
      }
     },
     "400": {
      "description": "\"Nothing to update.\", a date that is not a real calendar day, or an unknown rightToWorkStatus"
     },
     "404": {
      "description": "\"Employee not found.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_records",
    "x-permissions-named": [
     "manage_employee_records"
    ],
    "x-typed-objects": [
     "EmployeeFile",
     "EmployeeProfileUpdate"
    ],
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/{userId}/reveal": {
   "post": {
    "operationId": "post_user_management_employees_userId_reveal",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "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.",
    "description": "Request, as the catalogue words it: The SensitiveFieldReveal object: { field: 'dateOfBirth' | 'homeAddress' }\n\nReply, as the catalogue words it: 200 { field:string, value:string|null } (value is null when nothing is stored) | 400 for any other field | 404 \"Employee not found.\"\n\nGuard: Repeat: safe; it is a read, and every call is written to the audit log as EMPLOYEE_SENSITIVE_FIELD_VIEWED\n\nAuth: Session + view_employee_records | manage_employee_records",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The SensitiveFieldReveal object: { field: 'dateOfBirth' | 'homeAddress' }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/SensitiveFieldReveal"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ field:string, value:string|null } (value is null when nothing is stored)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "field": {
           "type": "string"
          },
          "value": {
           "type": [
            "string",
            "null"
           ]
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "for any other field"
     },
     "404": {
      "description": "\"Employee not found.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_employee_records | manage_employee_records",
    "x-permissions-named": [
     "manage_employee_records",
     "view_employee_records"
    ],
    "x-typed-objects": [
     "SensitiveFieldReveal"
    ],
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/{userId}/emergency-contacts": {
   "put": {
    "operationId": "put_user_management_employees_userId_emergency_contacts",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "Replace a person's emergency contacts.",
    "description": "Request, as the catalogue words it: { contacts:[ EmergencyContactInput ] }: each { name:string (required), relationship?:string, phone?:string (up to 40 characters) }, at most 5, kept in the order sent.\n\nReply, as the catalogue words it: 200 the EmployeeFile object | 400 \"contacts must be a list.\", \"At most 5 emergency contacts.\" or \"Each emergency contact needs a name.\"\n\nGuard: Repeat: safe; the list sent replaces the stored list.\n\nAuth: Session + manage_employee_records",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "{ contacts:[ EmergencyContactInput ] }: each { name:string (required), relationship?:string, phone?:string (up to 40 characters) }, at most 5, kept in the order sent.",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "contacts": {
          "type": "array",
          "items": {
           "$ref": "#/components/schemas/EmergencyContactInput"
          }
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the EmployeeFile object",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/EmployeeFile"
        }
       }
      }
     },
     "400": {
      "description": "\"contacts must be a list.\", \"At most 5 emergency contacts.\" or \"Each emergency contact needs a name.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_records",
    "x-permissions-named": [
     "manage_employee_records"
    ],
    "x-typed-objects": [
     "EmployeeFile",
     "EmergencyContactInput"
    ],
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/{userId}/job-changes": {
   "post": {
    "operationId": "post_user_management_employees_userId_job_changes",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "Record a job change: title, department, manager, level or location (cancel one with DELETE /job-changes/:eventId).",
    "description": "Request, as the catalogue words it: 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) }\n\nReply, as the catalogue words it: 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.\"\n\nGuard: 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.\n\nAuth: Session + manage_employee_records",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/JobChangeCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "the EmployeeFile object",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/EmployeeFile"
        }
       }
      }
     },
     "400": {
      "description": "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 | \"Only a scheduled change can be cancelled.\""
     },
     "404": {
      "description": "\"Job change not found.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_records",
    "x-permissions-named": [
     "manage_employee_records"
    ],
    "x-typed-objects": [
     "EmployeeFile",
     "JobChangeCreate"
    ],
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/{userId}/job-changes/{eventId}": {
   "delete": {
    "operationId": "delete_user_management_employees_userId_job_changes_eventId",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "Cancel a job change that is scheduled for a later day.",
    "description": "Request, as the catalogue words it: No body. eventId is the id of the scheduled change in the employee file.\n\nReply, as the catalogue words it: 200 the EmployeeFile object | 400 \"Only a scheduled change can be cancelled.\" | 404 \"Job change not found.\" or \"Employee not found.\"\n\nGuard: Repeat: a change that is already cancelled answers 400. The cancellation is written to the audit log.\n\nAuth: Session + manage_employee_records",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     },
     {
      "name": "eventId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: EmploymentEvent primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "the EmployeeFile object",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/EmployeeFile"
        }
       }
      }
     },
     "400": {
      "description": "\"Only a scheduled change can be cancelled.\""
     },
     "404": {
      "description": "\"Job change not found.\" or \"Employee not found.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_records",
    "x-permissions-named": [
     "manage_employee_records"
    ],
    "x-typed-objects": [
     "EmployeeFile"
    ],
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/{userId}/departure": {
   "get": {
    "operationId": "get_user_management_employees_userId_departure",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "The leaving record and HR's exit checklist (save with PUT, manage_employee_records).",
    "description": "Request, as the catalogue words it: 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 } }\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Repeat: safe; a person has one open leaving record and PUT updates it in place.\n\nAuth: Session + view_employee_records | manage_employee_records",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "departure": {
           "anyOf": [
            {
             "$ref": "#/components/schemas/DepartureRecord"
            },
            {
             "type": "null"
            }
           ],
           "description": "null until one is recorded"
          },
          "directReports": {
           "type": "integer"
          },
          "types": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "key": {
              "type": "string"
             },
             "label": {
              "type": "string"
             }
            }
           }
          },
          "reasons": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "key": {
              "type": "string"
             },
             "label": {
              "type": "string"
             },
             "type": {
              "type": [
               "string",
               "null"
              ],
              "description": "the departure type the reason belongs to"
             }
            }
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"Set the last day first.\", \"Unknown departure type.\", \"Unknown reason.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_employee_records | manage_employee_records",
    "x-permissions-named": [
     "manage_employee_records",
     "view_employee_records"
    ],
    "x-typed-objects": [
     "DepartureRecord"
    ],
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/me": {
   "get": {
    "operationId": "get_user_management_employees_me",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "Your own file (a summary at /me/summary).",
    "description": "Reply, as the catalogue words it: 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 }.\n\nAuth: Session",
    "responses": {
     "200": {
      "description": "{ 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 }.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "user": {
           "type": "object",
           "properties": {
            "id": {
             "type": "integer"
            },
            "name": {
             "type": "string"
            },
            "firstName": {
             "type": [
              "string",
              "null"
             ]
            },
            "lastName": {
             "type": [
              "string",
              "null"
             ]
            },
            "email": {
             "type": "string"
            },
            "position": {
             "type": [
              "string",
              "null"
             ]
            },
            "department": {
             "type": [
              "string",
              "null"
             ]
            },
            "managerName": {
             "type": [
              "string",
              "null"
             ]
            },
            "employeeId": {
             "type": [
              "string",
              "null"
             ]
            },
            "phoneNumber": {
             "type": [
              "string",
              "null"
             ]
            },
            "personalEmail": {
             "type": [
              "string",
              "null"
             ]
            }
           }
          },
          "profile": {
           "type": "object",
           "properties": {
            "dateOfBirth": {
             "type": [
              "string",
              "null"
             ]
            },
            "homeAddress": {
             "type": [
              "string",
              "null"
             ]
            }
           },
           "x-keys-not-described": [
            "preferredName",
            "legalName",
            "nationality",
            "hireDate",
            "originalHireDate",
            "employmentType",
            "workLocation",
            "level",
            "probationEndDate",
            "contractEndDate",
            "noticePeriod"
           ]
          },
          "emergencyContacts": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/EmergencyContact"
           }
          },
          "history": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "id": {
              "type": "integer"
             },
             "type": {
              "type": "string"
             },
             "effectiveDate": {
              "type": "string",
              "format": "date"
             },
             "changes": {
              "type": "object"
             }
            },
            "x-keys-not-described": [
             "status"
            ]
           },
           "description": "applied changes among the newest 100"
          },
          "equipment": {
           "type": [
            "array",
            "null"
           ],
           "items": {
            "type": "object",
            "properties": {
             "id": {
              "type": "integer"
             }
            },
            "x-keys-not-described": [
             "name",
             "type",
             "manufacturer",
             "serialNumber",
             "status",
             "platform"
            ]
           },
           "description": "null when the device list cannot be read"
          },
          "directReports": {
           "type": "integer"
          },
          "connectedTo": {
           "type": [
            "string",
            "null"
           ],
           "description": "the connected HR system's name"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-typed-objects": [
     "EmployeeProfile",
     "EmergencyContact"
    ],
    "x-catalogue-group": "hr-employee-files"
   },
   "put": {
    "operationId": "put_user_management_employees_me",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "Update your own personal details (your emergency contacts at PUT /me/emergency-contacts).",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Any other field in the body is ignored. Repeat: safe.\n\nAuth: Session",
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/MyDetailsUpdate"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "your file, in the shape of GET /employees/me"
     },
     "400": {
      "description": "\"Nothing to update.\", \"Personal email is not a valid email address.\" or \"Personal email must be different from your work email.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-typed-objects": [
     "MyDetailsUpdate"
    ],
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/my-team": {
   "get": {
    "operationId": "get_user_management_employees_my_team",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "The people below you in the reporting line (one person's reduced file at /my-team/:userId).",
    "description": "Reply, as the catalogue words it: 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.\"\n\nGuard: Opening one person's file is written to the audit log.\n\nAuth: Session",
    "responses": {
     "200": {
      "description": "{ 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) }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "team": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "id": {
              "type": "integer"
             },
             "name": {
              "type": "string"
             },
             "email": {
              "type": "string"
             },
             "position": {
              "type": [
               "string",
               "null"
              ]
             },
             "department": {
              "type": [
               "string",
               "null"
              ]
             },
             "status": {
              "type": [
               "string",
               "null"
              ]
             },
             "depth": {
              "type": "integer",
              "description": "1 for a direct report"
             },
             "direct": {
              "type": "boolean"
             },
             "reportsTo": {
              "type": [
               "string",
               "null"
              ],
              "description": "null for a direct report"
             },
             "hireDate": {
              "type": [
               "string",
               "null"
              ],
              "format": "date"
             },
             "workLocation": {
              "type": [
               "string",
               "null"
              ]
             },
             "reminders": {
              "type": "array",
              "items": {
               "$ref": "#/components/schemas/EmployeeReminder"
              }
             }
            }
           }
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "\"This person is not in your team.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session",
    "x-permissions-named": [],
    "x-typed-objects": [
     "EmployeeReminder",
     "EmployeeProfile"
    ],
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/hr-settings": {
   "get": {
    "operationId": "get_user_management_employees_hr_settings",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "Read HR Settings, including which system owns people data.",
    "description": "Reply, as the catalogue words it: 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 }.\n\nAuth: Session + view_employee_records | manage_employee_records",
    "responses": {
     "200": {
      "description": "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 }.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_employee_records | manage_employee_records",
    "x-permissions-named": [
     "manage_employee_records",
     "view_employee_records"
    ],
    "x-typed-objects": [
     "HrAuthority"
    ],
    "x-catalogue-group": "hr-employee-files"
   },
   "put": {
    "operationId": "put_user_management_employees_hr_settings",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "Change HR Settings.",
    "description": "Request, as the catalogue words it: The HrAuthorityWrite object: { mode (required: 'zonitix' | 'external'), providerId (required for external: a connected HR system), fields? { <field key>: 'zonitix' | 'external' } }\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Written to the audit log at warning level with the before and after. Repeat: safe.\n\nAuth: Session + manage_employee_records",
    "requestBody": {
     "description": "The HrAuthorityWrite object: { mode (required: 'zonitix' | 'external'), providerId (required for external: a connected HR system), fields? { <field key>: 'zonitix' | 'external' } }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/HrAuthorityWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the same shape as the GET"
     },
     "400": {
      "description": "\"mode must be 'zonitix' or 'external'.\" or \"Choose a connected HRIS. Add one under Identity Hooks first.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_records",
    "x-permissions-named": [
     "manage_employee_records"
    ],
    "x-typed-objects": [
     "HrAuthorityWrite"
    ],
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/hr-settings/retention": {
   "put": {
    "operationId": "put_user_management_employees_hr_settings_retention",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "Set how long the sensitive HR data of people who have left is kept.",
    "description": "Request, as the catalogue words it: The HrRetentionWrite object: { months } required: 0 switches it off, otherwise a whole number from 6 to 240.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_employee_records",
    "requestBody": {
     "description": "The HrRetentionWrite object: { months } required: 0 switches it off, otherwise a whole number from 6 to 240.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/HrRetentionWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "the same shape as GET /employees/hr-settings"
     },
     "400": {
      "description": "'Choose 0 to switch it off, or between 6 and 240 months.' for any other value"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_records",
    "x-permissions-named": [
     "manage_employee_records"
    ],
    "x-typed-objects": [
     "HrRetentionWrite"
    ],
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/{userId}/compensation": {
   "get": {
    "operationId": "get_user_management_employees_userId_compensation",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "A person's compensation history. Amounts are not included; reveal one at a time.",
    "description": "Reply, as the catalogue words it: 200 { records:[ CompensationRecord ] }, newest effective date first, no paging. The object carries no amount.\n\nAuth: Session + view_compensation | manage_compensation (licensed)",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ records:[ CompensationRecord ] }, newest effective date first, no paging. The object carries no amount.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "records": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/CompensationRecord"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_compensation | manage_compensation (licensed)",
    "x-permissions-named": [
     "manage_compensation",
     "view_compensation"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CompensationRecord"
    ],
    "x-catalogue-group": "hr-employee-files"
   },
   "post": {
    "operationId": "post_user_management_employees_userId_compensation",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "Add a compensation record to a file.",
    "description": "Request, as the catalogue words it: 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) }\n\nReply, as the catalogue words it: 201 { record:CompensationRecord } (no amount) | 400 with the field at fault | 404 \"Employee not found.\"\n\nGuard: The amount is never written to the audit log. Repeat: not deduplicated; a second call adds a second record, which you then void.\n\nAuth: Session + manage_compensation (licensed)",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/CompensationCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ record:CompensationRecord } (no amount)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "record": {
           "$ref": "#/components/schemas/CompensationRecord"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "with the field at fault"
     },
     "404": {
      "description": "\"Employee not found.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compensation (licensed)",
    "x-permissions-named": [
     "manage_compensation"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CompensationRecord",
     "CompensationCreate"
    ],
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/{userId}/compensation/{recordId}/reveal": {
   "post": {
    "operationId": "post_user_management_employees_userId_compensation_recordId_reveal",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "Reveal the amount of one compensation record. Each reveal is written to the audit log.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { id:integer, amount:number|null (null when the stored value cannot be read) } | 404 \"Compensation record not found.\"\n\nGuard: 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\n\nAuth: Session + view_compensation | manage_compensation (licensed)",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     },
     {
      "name": "recordId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: CompensationRecord primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ id:integer, amount:number|null (null when the stored value cannot be read) }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "integer"
          },
          "amount": {
           "type": [
            "number",
            "null"
           ],
           "description": "null when the stored value cannot be read"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "\"Compensation record not found.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_compensation | manage_compensation (licensed)",
    "x-permissions-named": [
     "manage_compensation",
     "view_compensation"
    ],
    "x-licensed": true,
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/{userId}/compensation/{recordId}/void": {
   "post": {
    "operationId": "post_user_management_employees_userId_compensation_recordId_void",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "Void a mistyped record. It is kept, marked, and left out of the payroll hand-off; records are never edited or deleted.",
    "description": "Request, as the catalogue words it: The CompensationVoid object: { reason:string } required, up to 255 characters.\n\nReply, as the catalogue words it: 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\n\nGuard: Only a record a person typed in can be voided. A void cannot be undone. Repeat: a record that is already voided answers 409\n\nAuth: Session + manage_compensation (licensed)",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     },
     {
      "name": "recordId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: CompensationRecord primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The CompensationVoid object: { reason:string } required, up to 255 characters.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/CompensationVoid"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ record:CompensationRecord } with voidedAt, voidedBy and voidReason set",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "record": {
           "$ref": "#/components/schemas/CompensationRecord"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "when the reason is missing or the record came from a signed offer"
     },
     "404": {
      "description": "'Compensation record not found.'"
     },
     "409": {
      "description": "'This record was already voided.'"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compensation (licensed)",
    "x-permissions-named": [
     "manage_compensation"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CompensationRecord",
     "CompensationVoid"
    ],
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/{userId}/documents": {
   "get": {
    "operationId": "get_user_management_employees_userId_documents",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "The documents on a person's file (download one at /documents/:docId/download).",
    "description": "Reply, as the catalogue words it: 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.\"\n\nGuard: Every download is written to the audit log.\n\nAuth: Session + view_employee_records | manage_employee_records (licensed)",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "documents": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/EmployeeDocument"
           },
           "description": "newest first; entries whose file was never uploaded are left out"
          },
          "categories": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "key": {
              "type": "string"
             },
             "label": {
              "type": "string"
             }
            }
           }
          },
          "visibilities": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "key": {
              "type": "string"
             },
             "label": {
              "type": "string"
             }
            }
           }
          },
          "maxBytes": {
           "type": "integer",
           "description": "10485760"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "\"Document not found.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_employee_records | manage_employee_records (licensed)",
    "x-permissions-named": [
     "manage_employee_records",
     "view_employee_records"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "EmployeeDocument"
    ],
    "x-catalogue-group": "hr-employee-files"
   },
   "post": {
    "operationId": "post_user_management_employees_userId_documents",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "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).",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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.\n\nGuard: 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.\n\nAuth: Session + manage_employee_records (licensed)",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/EmployeeDocumentWrite"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ document:EmployeeDocument }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "document": {
           "$ref": "#/components/schemas/EmployeeDocument"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"A document needs a name.\", \"Unknown category.\", \"Unknown visibility.\" or when the file already holds 100 documents. Content: 200 { document:EmployeeDocument } | \"The file is empty.\"",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "409": {
      "description": "\"This document already has its file.\""
     },
     "413": {
      "description": "over 10 MB"
     },
     "415": {
      "description": "unless PDF, PNG, JPEG, Word (.docx) or Excel (.xlsx). PUT /documents/:docId: 200 { document:EmployeeDocument }. DELETE: 204.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_records (licensed)",
    "x-permissions-named": [
     "manage_employee_records"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "EmployeeDocument",
     "EmployeeDocumentWrite"
    ],
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/{userId}/documents/{docId}/content": {
   "put": {
    "operationId": "put_user_management_employees_userId_documents_docId_content",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "Upload the file of a document entry made with POST /documents.",
    "description": "Request, as the catalogue words it: The file itself as the raw request body, at most 10 MB: PDF, PNG, JPEG, Word (.docx) or Excel (.xlsx).\n\nReply, as the catalogue words it: 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\n\nGuard: The file type is decided from the file's own bytes. Repeat: a second upload to the same entry answers 409 and stores nothing.\n\nAuth: Session + manage_employee_records (licensed)",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     },
     {
      "name": "docId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: EmployeeDocument primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The file itself as the raw request body, at most 10 MB: PDF, PNG, JPEG, Word (.docx) or Excel (.xlsx).",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ document:EmployeeDocument }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "document": {
           "$ref": "#/components/schemas/EmployeeDocument"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"The file is empty.\""
     },
     "404": {
      "description": "\"Document not found.\""
     },
     "409": {
      "description": "\"This document already has its file. Upload a new document instead.\""
     },
     "413": {
      "description": "over 10 MB"
     },
     "415": {
      "description": "for any other file type"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_records (licensed)",
    "x-permissions-named": [
     "manage_employee_records"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "EmployeeDocument"
    ],
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/{userId}/documents/{docId}": {
   "put": {
    "operationId": "put_user_management_employees_userId_documents_docId",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "Rename a stored document or change its category, visibility or expiry date.",
    "description": "Request, as the catalogue words it: The EmployeeDocumentWrite object, all keys optional: { name?:string, category?, visibility?, expiresAt?:string (YYYY-MM-DD) or null }. Only the keys sent are changed.\n\nReply, as the catalogue words it: 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)\n\nGuard: Repeat: safe; the call sets values. The change is written to the audit log.\n\nAuth: Session + manage_employee_records (licensed)",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     },
     {
      "name": "docId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: EmployeeDocument primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The EmployeeDocumentWrite object, all keys optional: { name?:string, category?, visibility?, expiresAt?:string (YYYY-MM-DD) or null }. Only the keys sent are changed.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/EmployeeDocumentWrite"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ document:EmployeeDocument }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "document": {
           "$ref": "#/components/schemas/EmployeeDocument"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"Nothing to update.\", \"A document needs a name.\", \"Unknown category.\" or \"Unknown visibility.\""
     },
     "404": {
      "description": "\"Document not found.\" (also for an entry whose file was never uploaded)"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_records (licensed)",
    "x-permissions-named": [
     "manage_employee_records"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "EmployeeDocument",
     "EmployeeDocumentWrite"
    ],
    "x-catalogue-group": "hr-employee-files"
   },
   "delete": {
    "operationId": "delete_user_management_employees_userId_documents_docId",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "Delete a document from an employee file.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 204 with no body | 404 \"Document not found.\"\n\nGuard: Permanent. Written to the audit log at warning level. Repeat: a second call answers 404.\n\nAuth: Session + manage_employee_records (licensed)",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     },
     {
      "name": "docId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: EmployeeDocument primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "204": {
      "description": "with no body"
     },
     "404": {
      "description": "\"Document not found.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_records (licensed)",
    "x-permissions-named": [
     "manage_employee_records"
    ],
    "x-licensed": true,
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/{userId}/documents/{docId}/download": {
   "get": {
    "operationId": "get_user_management_employees_userId_documents_docId_download",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "Download one document from an employee file.",
    "description": "Request, as the catalogue words it: No query parameters.\n\nReply, as the catalogue words it: 200 the file as an attachment | 404 \"Document not found.\"\n\nGuard: Every download is written to the audit log.\n\nAuth: Session + view_employee_records | manage_employee_records (licensed)",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     },
     {
      "name": "docId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: EmployeeDocument primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "the file as an attachment"
     },
     "404": {
      "description": "\"Document not found.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_employee_records | manage_employee_records (licensed)",
    "x-permissions-named": [
     "manage_employee_records",
     "view_employee_records"
    ],
    "x-licensed": true,
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/me/documents": {
   "get": {
    "operationId": "get_user_management_employees_me_documents",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "Your own documents that are shared with you (download at /me/documents/:docId/download).",
    "description": "Reply, as the catalogue words it: 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).\n\nAuth: Session (licensed)",
    "responses": {
     "200": {
      "description": "{ 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).",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "documents": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/EmployeeDocument"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "EmployeeDocument"
    ],
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/my-team/{userId}/documents": {
   "get": {
    "operationId": "get_user_management_employees_my_team_userId_documents",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "A report's documents that are shared with their manager (download at /documents/:docId/download).",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ documents:[ EmployeeDocument ] }: only entries whose visibility is manager",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "documents": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/EmployeeDocument"
           }
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "\"This person is not in your team.\" Not paged: every such document in one reply, newest first."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "EmployeeDocument"
    ],
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/reports/summary": {
   "get": {
    "operationId": "get_user_management_employees_reports_summary",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "People Reports: combined figures only.",
    "description": "Request, as the catalogue words it: Query from, to (YYYY-MM-DD).\n\nReply, as the catalogue words it: 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.\"\n\nAuth: Session + view_employee_reports (licensed)",
    "responses": {
     "200": {
      "description": "{ 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 }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "from": {
           "type": "string",
           "format": "date"
          },
          "to": {
           "type": "string",
           "format": "date"
          },
          "headcount": {
           "type": "integer"
          },
          "startHeadcount": {
           "type": "integer"
          },
          "joiners": {
           "type": "integer"
          },
          "leavers": {
           "type": "integer"
          },
          "turnoverPct": {
           "type": [
            "number",
            "null"
           ]
          },
          "averageTenureYears": {
           "type": [
            "number",
            "null"
           ]
          },
          "missingStartDate": {
           "type": "integer"
          },
          "tenureBands": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "label": {
              "type": "string"
             },
             "count": {
              "type": "integer"
             }
            }
           }
          }
         },
         "x-keys-not-described": [
          "byDepartment",
          "byLocation",
          "byEmploymentType",
          "leaversByType",
          "leaversByReason"
         ]
        }
       }
      }
     },
     "400": {
      "description": "\"Choose a valid date range.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_employee_reports (licensed)",
    "x-permissions-named": [
     "view_employee_reports"
    ],
    "x-licensed": true,
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/employees/payroll-export": {
   "get": {
    "operationId": "get_user_management_employees_payroll_export",
    "tags": [
     "Employee Files, Pay History, Documents & Payroll Hand-off"
    ],
    "summary": "Build the payroll hand-off file for a period (the last one built at /payroll-export/last).",
    "description": "Request, as the catalogue words it: Query from, to (YYYY-MM-DD, both required; at most a year apart).\n\nReply, as the catalogue words it: 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.\n\nGuard: 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.\n\nAuth: Session + export_compensation (licensed)",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "from": {
           "type": "string",
           "format": "date"
          },
          "to": {
           "type": "string",
           "format": "date"
          },
          "joiners": {
           "type": "array",
           "items": {
            "type": "object"
           }
          },
          "leavers": {
           "type": "array",
           "items": {
            "type": "object"
           }
          },
          "payChanges": {
           "type": "array",
           "items": {
            "type": "object"
           }
          },
          "leave": {
           "type": "array",
           "items": {
            "type": "object"
           }
          },
          "counts": {
           "type": "object",
           "properties": {
            "joiners": {
             "type": "integer"
            },
            "leavers": {
             "type": "integer"
            },
            "payChanges": {
             "type": "integer"
            },
            "leave": {
             "type": "integer"
            }
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"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.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + export_compensation (licensed)",
    "x-permissions-named": [
     "export_compensation"
    ],
    "x-licensed": true,
    "x-catalogue-group": "hr-employee-files"
   }
  },
  "/user-management/leave/me": {
   "get": {
    "operationId": "get_user_management_leave_me",
    "tags": [
     "Time Off"
    ],
    "summary": "Your balances by type and your requests.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "balances": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/LeaveBalance"
           },
           "description": "one per active type"
          },
          "requests": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/LeaveRequest"
           }
          },
          "canApprove": {
           "type": "boolean"
          },
          "today": {
           "type": "string",
           "format": "date"
          },
          "hoursPerDay": {
           "type": "number"
          },
          "dayStart": {
           "type": "string",
           "description": "HH:MM"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "LeaveBalance",
     "LeaveRequest"
    ],
    "x-catalogue-group": "hr-time-off"
   }
  },
  "/user-management/leave/preview": {
   "get": {
    "operationId": "get_user_management_leave_preview",
    "tags": [
     "Time Off"
    ],
    "summary": "How many working days a request would use, before it is sent.",
    "description": "Request, as the catalogue words it: Query leaveTypeId, startDate, endDate, startHalf, endHalf ('true'), minutes, startTime: the same fields as a request.\n\nReply, as the catalogue words it: 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.\"\n\nAuth: Session (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.\"",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "startHalf": {
           "type": "boolean"
          },
          "endHalf": {
           "type": "boolean"
          },
          "minutes": {
           "type": [
            "integer",
            "null"
           ]
          },
          "calendar": {
           "type": "string"
          },
          "hoursPerDay": {
           "type": "number"
          },
          "dayStart": {
           "type": "string"
          },
          "holidays": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "name": {
              "type": "string"
             }
            },
            "x-keys-not-described": [
             "date"
            ]
           }
          },
          "days": {
           "type": "number"
          },
          "cost": {
           "type": "number"
          },
          "dayMinutes": {
           "type": [
            "integer",
            "null"
           ]
          },
          "clashes": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/LeaveRequest"
           }
          },
          "replaceable": {
           "type": "boolean"
          }
         },
         "x-keys-not-described": [
          "startDate",
          "endDate",
          "startTime",
          "endTime"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "LeaveRequest"
    ],
    "x-catalogue-group": "hr-time-off"
   }
  },
  "/user-management/leave/requests": {
   "post": {
    "operationId": "post_user_management_leave_requests",
    "tags": [
     "Time Off"
    ],
    "summary": "Request time off, in whole or half days or by the hour.",
    "description": "Request, as the catalogue words it: 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).\n\nReply, as the catalogue words it: 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\n\nGuard: 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)\n\nAuth: Session (licensed)",
    "requestBody": {
     "description": "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).",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/LeaveRequestCreate"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ 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) }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "request": {
           "$ref": "#/components/schemas/LeaveRequest"
          },
          "requests": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/LeaveRequest"
           },
           "description": "every request made, one per week of a repeat"
          },
          "skipped": {
           "type": "array",
           "items": {
            "type": "string"
           },
           "description": "the dates of a repeat that were left out because they are not working days"
          },
          "replaced": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/LeaveRequest"
           },
           "description": "bookings by the hour the new request replaced"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "with the reason in words (dates, limits of the type, balance)"
     },
     "409": {
      "description": "when it overlaps time off the person already has waiting or approved"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "LeaveRequest",
     "LeaveRequestCreate"
    ],
    "x-catalogue-group": "hr-time-off"
   }
  },
  "/user-management/leave/requests/{id}/cancel": {
   "post": {
    "operationId": "post_user_management_leave_requests_id_cancel",
    "tags": [
     "Time Off"
    ],
    "summary": "Cancel a request.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { request:LeaveRequest } with status cancelled and no typeName\n\nGuard: 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\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: LeaveRequest primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ request:LeaveRequest } with status cancelled and no typeName",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "request": {
           "$ref": "#/components/schemas/LeaveRequest"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "LeaveRequest"
    ],
    "x-catalogue-group": "hr-time-off"
   }
  },
  "/user-management/leave/approvals": {
   "get": {
    "operationId": "get_user_management_leave_approvals",
    "tags": [
     "Time Off"
    ],
    "summary": "The requests waiting for your decision.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "approvals": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/LeaveRequest"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "LeaveRequest"
    ],
    "x-catalogue-group": "hr-time-off"
   }
  },
  "/user-management/leave/requests/{id}/decide": {
   "post": {
    "operationId": "post_user_management_leave_requests_id_decide",
    "tags": [
     "Time Off"
    ],
    "summary": "Approve or decline one request. Nobody can decide their own.",
    "description": "Request, as the catalogue words it: The LeaveDecision object: { decision: 'approve' | 'decline', note? } — note is required for a decline (up to 500 characters) and is shown to the person.\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: LeaveRequest primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The LeaveDecision object: { decision: 'approve' | 'decline', note? } — note is required for a decline (up to 500 characters) and is shown to the person.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/LeaveDecision"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ request:LeaveRequest }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "request": {
           "$ref": "#/components/schemas/LeaveRequest"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "bad decision or missing reason"
     },
     "404": {
      "description": "when the request does not exist or is not yours to decide"
     },
     "409": {
      "description": "when it was already decided"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "LeaveRequest",
     "LeaveDecision"
    ],
    "x-catalogue-group": "hr-time-off"
   }
  },
  "/user-management/leave/approvals/approve": {
   "post": {
    "operationId": "post_user_management_leave_approvals_approve",
    "tags": [
     "Time Off"
    ],
    "summary": "Approve several requests in one call. Each is decided exactly as a single decision would be.",
    "description": "Request, as the catalogue words it: The LeaveApproveMany object: { ids: number[] } — 1 to 50 request ids.\n\nReply, as the catalogue words it: 200 { approved:[ LeaveRequest ], failed:[{ id:integer, error:string }] } | 400 for an empty list or more than 50\n\nGuard: 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>.\"\n\nAuth: Session (licensed)",
    "requestBody": {
     "description": "The LeaveApproveMany object: { ids: number[] } — 1 to 50 request ids.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/LeaveApproveMany"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ approved:[ LeaveRequest ], failed:[{ id:integer, error:string }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "approved": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/LeaveRequest"
           }
          },
          "failed": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "id": {
              "type": "integer"
             },
             "error": {
              "type": "string"
             }
            }
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "for an empty list or more than 50"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "LeaveRequest",
     "LeaveApproveMany"
    ],
    "x-catalogue-group": "hr-time-off"
   }
  },
  "/user-management/leave/approvals/decline": {
   "post": {
    "operationId": "post_user_management_leave_approvals_decline",
    "tags": [
     "Time Off"
    ],
    "summary": "Decline several requests in one call, each with its own reason. Each is decided exactly as a single decline would be.",
    "description": "Request, as the catalogue words it: The LeaveDeclineMany object: { items:[ LeaveDeclineItem ] } with each item { id:integer, note:string } — 1 to 50 items; every note is required (up to 500 characters).\n\nReply, as the catalogue words it: 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)\n\nGuard: 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>.\"\n\nAuth: Session (licensed)",
    "requestBody": {
     "description": "The LeaveDeclineMany object: { items:[ LeaveDeclineItem ] } with each item { id:integer, note:string } — 1 to 50 items; every note is required (up to 500 characters).",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/LeaveDeclineMany"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ declined:[ LeaveRequest ], failed:[{ id:integer, error:string }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "declined": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/LeaveRequest"
           }
          },
          "failed": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "id": {
              "type": "integer"
             },
             "error": {
              "type": "string"
             }
            }
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "for an empty list, more than 50, or any missing reason (nothing is declined in that case)"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "LeaveRequest",
     "LeaveDeclineItem",
     "LeaveDeclineMany"
    ],
    "x-catalogue-group": "hr-time-off"
   }
  },
  "/user-management/leave/calendar": {
   "get": {
    "operationId": "get_user_management_leave_calendar",
    "tags": [
     "Time Off"
    ],
    "summary": "Who is out.",
    "description": "Request, as the catalogue words it: Query from, to (YYYY-MM-DD, both required; a year or less).\n\nReply, as the catalogue words it: 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.\n\nAuth: Session (licensed)",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "out": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "id": {
              "type": "integer"
             },
             "userId": {
              "type": "integer"
             },
             "personName": {
              "type": "string"
             },
             "startDate": {
              "type": "string",
              "format": "date"
             },
             "endDate": {
              "type": "string",
              "format": "date"
             },
             "startTime": {
              "type": [
               "string",
               "null"
              ]
             },
             "minutes": {
              "type": [
               "integer",
               "null"
              ]
             },
             "status": {
              "type": "string",
              "enum": [
               "pending",
               "approved"
              ]
             },
             "typeName": {
              "type": [
               "string",
               "null"
              ]
             },
             "mine": {
              "type": "boolean"
             },
             "workingDays": {
              "type": "array",
              "items": {
               "type": "string"
              }
             }
            }
           }
          },
          "today": {
           "type": "string",
           "format": "date"
          },
          "workDays": {
           "type": "array",
           "items": {
            "type": "integer"
           }
          },
          "holidays": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "date": {
              "type": "string",
              "format": "date"
             },
             "name": {
              "type": "string"
             }
            }
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"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."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "hr-time-off"
   }
  },
  "/user-management/leave/setup": {
   "get": {
    "operationId": "get_user_management_leave_setup",
    "tags": [
     "Time Off"
    ],
    "summary": "The types of time off and working calendars.",
    "description": "Reply, as the catalogue words it: 200 { types:[ LeaveType ] (by sort order, then name), calendars:[ WorkingCalendar ] (the default first, then by name) }. Not paged: every type and every calendar.\n\nAuth: Session + manage_leave (licensed)",
    "responses": {
     "200": {
      "description": "{ types:[ LeaveType ] (by sort order, then name), calendars:[ WorkingCalendar ] (the default first, then by name) }. Not paged: every type and every calendar.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "types": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/LeaveType"
           },
           "description": "by sort order, then name"
          },
          "calendars": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/WorkingCalendar"
           },
           "description": "the default first, then by name"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_leave (licensed)",
    "x-permissions-named": [
     "manage_leave"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "LeaveType",
     "WorkingCalendar"
    ],
    "x-catalogue-group": "hr-time-off"
   }
  },
  "/user-management/leave/types": {
   "post": {
    "operationId": "post_user_management_leave_types",
    "tags": [
     "Time Off"
    ],
    "summary": "Add a type of time off (change with PUT /types/:id, retire with DELETE /types/:id).",
    "description": "Request, as the catalogue words it: 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) }\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Repeat: a second POST with the same name is refused with 400.\n\nAuth: Session + manage_leave (licensed)",
    "requestBody": {
     "description": "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) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/LeaveTypeWrite"
       }
      }
     }
    },
    "responses": {
     "404": {
      "description": "\"Type of time off not found.\""
     },
     "default": {
      "description": "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 ...\"",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_leave (licensed)",
    "x-permissions-named": [
     "manage_leave"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "LeaveType",
     "LeaveTypeWrite"
    ],
    "x-catalogue-group": "hr-time-off"
   }
  },
  "/user-management/leave/calendars": {
   "post": {
    "operationId": "post_user_management_leave_calendars",
    "tags": [
     "Time Off"
    ],
    "summary": "Add a working calendar (change with PUT /calendars/:id, remove with DELETE /calendars/:id).",
    "description": "Request, as the catalogue words it: 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 }\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Repeat: not deduplicated: every call creates another calendar, also with the same name\n\nAuth: Session + manage_leave (licensed)",
    "requestBody": {
     "description": "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 }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/HolidayCalendarWrite"
       }
      }
     }
    },
    "responses": {
     "400": {
      "description": "with the reason, including \"Make another calendar the default before deleting this one.\""
     },
     "404": {
      "description": "\"Calendar not found.\""
     },
     "default": {
      "description": "POST, PUT and DELETE all answer 200 { calendars:[ WorkingCalendar ] }, the whole list",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_leave (licensed)",
    "x-permissions-named": [
     "manage_leave"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "WorkingCalendar",
     "HolidayCalendarWrite"
    ],
    "x-catalogue-group": "hr-time-off"
   }
  },
  "/user-management/leave/people/{userId}": {
   "get": {
    "operationId": "get_user_management_leave_people_userId",
    "tags": [
     "Time Off"
    ],
    "summary": "One person's balances, requests and balance history.",
    "description": "Reply, as the catalogue words it: 200 { balances:[ LeaveBalance ], requests:[ LeaveRequest ], hoursPerDay:number, dayStart:string (HH:MM) }; balances include retired types, requests is the newest 100 by start date.\n\nAuth: Session + manage_leave | view_employee_records | manage_employee_records (licensed)",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ balances:[ LeaveBalance ], requests:[ LeaveRequest ], hoursPerDay:number, dayStart:string (HH:MM) }; balances include retired types, requests is the newest 100 by start date.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "balances": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/LeaveBalance"
           }
          },
          "requests": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/LeaveRequest"
           }
          },
          "hoursPerDay": {
           "type": "number"
          },
          "dayStart": {
           "type": "string",
           "description": "HH:MM"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_leave | view_employee_records | manage_employee_records (licensed)",
    "x-permissions-named": [
     "manage_employee_records",
     "manage_leave",
     "view_employee_records"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "LeaveBalance",
     "LeaveRequest"
    ],
    "x-catalogue-group": "hr-time-off"
   }
  },
  "/user-management/leave/people/{userId}/adjust": {
   "post": {
    "operationId": "post_user_management_leave_people_userId_adjust",
    "tags": [
     "Time Off"
    ],
    "summary": "Adjust a person's balance, with a reason.",
    "description": "Request, as the catalogue words it: The LeaveBalanceAdjust object: { leaveTypeId:integer, days:number (not 0, between -366 and 366; negative removes), note:string }, all required.\n\nReply, as the catalogue words it: 201 { balances:[ LeaveBalance ] } (retired types included) | 400 for a type with no allowance, a bad number of days or a missing reason\n\nGuard: 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\n\nAuth: Session + manage_leave (licensed)",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The LeaveBalanceAdjust object: { leaveTypeId:integer, days:number (not 0, between -366 and 366; negative removes), note:string }, all required.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/LeaveBalanceAdjust"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ balances:[ LeaveBalance ] } (retired types included)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "balances": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/LeaveBalance"
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "for a type with no allowance, a bad number of days or a missing reason"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_leave (licensed)",
    "x-permissions-named": [
     "manage_leave"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "LeaveBalance",
     "LeaveBalanceAdjust"
    ],
    "x-catalogue-group": "hr-time-off"
   }
  },
  "/user-management/goals/mine": {
   "get": {
    "operationId": "get_user_management_goals_mine",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "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).",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session (licensed)",
    "responses": {
     "200": {
      "description": "{ person:{ id:integer, name:string }, canEdit:boolean, goals:[ Goal ] }; everything, no paging (a person has at most 50 goals)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "person": {
           "type": "object",
           "properties": {
            "id": {
             "type": "integer"
            },
            "name": {
             "type": "string"
            }
           }
          },
          "canEdit": {
           "type": "boolean"
          },
          "goals": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/Goal"
           }
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "\"Not found.\" for a person whose goals you may not read."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "Goal"
    ],
    "x-catalogue-group": "hr-goals-reviews-surveys"
   }
  },
  "/user-management/goals": {
   "post": {
    "operationId": "post_user_management_goals",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "Add a goal (change with PUT /goals/:id, remove with DELETE /goals/:id).",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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.\n\nGuard: Repeat: POST is not deduplicated.\n\nAuth: Session (licensed)",
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/GoalWrite"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "goal": {
           "$ref": "#/components/schemas/Goal"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "\"Not found.\" when the goal does not exist or is not yours to change."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "Goal",
     "GoalWrite"
    ],
    "x-catalogue-group": "hr-goals-reviews-surveys"
   }
  },
  "/user-management/goals/templates": {
   "get": {
    "operationId": "get_user_management_goals_templates",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "The example goals. Adding, changing or deleting one (POST, PUT /templates/:id, DELETE /templates/:id) is refused with 403 without manage_employee_records.",
    "description": "Request, as the catalogue words it: Write: { title (required), description? (up to 2,000 characters), isActive? (default true), sortOrder? }\n\nReply, as the catalogue words it: 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).\n\nAuth: Session (licensed)",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "canManage": {
           "type": "boolean"
          },
          "templates": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/GoalTemplate"
           }
          }
         }
        }
       }
      }
     },
     "403": {
      "description": "\"Only HR can do this.\""
     },
     "404": {
      "description": "\"Example not found.\" The GET is not paged: every example in one reply (at most 30 can exist)."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "GoalTemplate"
    ],
    "x-catalogue-group": "hr-goals-reviews-surveys"
   }
  },
  "/user-management/reviews/mine": {
   "get": {
    "operationId": "get_user_management_reviews_mine",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "Your reviews, the reviews you have to write, and feedback asked of you.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "mine": {
           "type": "array",
           "items": {
            "type": "object"
           }
          },
          "toReview": {
           "type": "array",
           "items": {
            "type": "object"
           }
          },
          "toGive": {
           "type": "array",
           "items": {
            "type": "object"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "ReviewCycle"
    ],
    "x-catalogue-group": "hr-goals-reviews-surveys"
   }
  },
  "/user-management/reviews/{reviewId}/self": {
   "put": {
    "operationId": "put_user_management_reviews_reviewId_self",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "Save or submit your self review (the reviewer's half at PUT /:reviewId/manager).",
    "description": "Request, as the catalogue words it: { answers, submit?, goalRatings? }. The reviewer's half also takes rating (a whole number 1 to 5, or null).\n\nReply, as the catalogue words it: 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.\n\nGuard: Without submit the answers are saved as a draft and can be sent again. Repeat: after submit, a second call is refused with 409.\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "reviewId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: PerformanceReview primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "{ answers, submit?, goalRatings? }. The reviewer's half also takes rating (a whole number 1 to 5, or null).",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok:true, submitted:boolean }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "submitted": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"Answer every question before submitting.\", \"Choose a rating before submitting.\""
     },
     "404": {
      "description": "\"Review not found.\""
     },
     "409": {
      "description": "when your half is already submitted, the review is already shared, it is with HR for calibration, or the cycle is closed."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "hr-goals-reviews-surveys"
   }
  },
  "/user-management/reviews/{reviewId}/share": {
   "post": {
    "operationId": "post_user_management_reviews_reviewId_share",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "The reviewer shares the review with its person.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 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.\n\nGuard: Repeat: safe; sharing a review that is already shared changes nothing and notifies nobody again.\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "reviewId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: PerformanceReview primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ ok: true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"Submit your review before sharing it.\""
     },
     "404": {
      "description": "\"Review not found.\""
     },
     "409": {
      "description": "when HR has not released the ratings for the cycle, or the cycle is closed."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "hr-goals-reviews-surveys"
   }
  },
  "/user-management/reviews/{reviewId}/feedback": {
   "post": {
    "operationId": "post_user_management_reviews_reviewId_feedback",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "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).",
    "description": "Request, as the catalogue words it: 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? }.\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Repeat: asking the same colleague again adds nothing; added counts only new requests.\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "reviewId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: PerformanceReview primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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? }.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/FeedbackRequest"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ added:integer }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "added": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"Choose at least one colleague.\" and the other rules above"
     },
     "404": {
      "description": "\"Review not found.\""
     },
     "409": {
      "description": "when the cycle is closed, does not use colleague feedback, or the review is already shared. Answer: 200 { ok:true, submitted:boolean } | when already submitted or declined. Decline: 200 { ok: true }. Withdraw (DELETE): 204 | \"Feedback that has been submitted cannot be removed.\"",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "FeedbackRequest"
    ],
    "x-catalogue-group": "hr-goals-reviews-surveys"
   }
  },
  "/user-management/reviews/cycles": {
   "get": {
    "operationId": "get_user_management_reviews_cycles",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "List review cycles.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + manage_employee_reviews (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "cycles": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/ReviewCycle"
           }
          },
          "defaultQuestions": {
           "type": "array",
           "items": {
            "type": "object"
           },
           "description": "the starting questions, in the shape of ReviewCycle.questions"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_reviews (licensed)",
    "x-permissions-named": [
     "manage_employee_reviews"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "ReviewCycle"
    ],
    "x-catalogue-group": "hr-goals-reviews-surveys"
   },
   "post": {
    "operationId": "post_user_management_reviews_cycles",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "Create a review cycle (change with PUT /cycles/:id; DELETE /cycles/:id removes a draft only).",
    "description": "Request, as the catalogue words it: 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) }\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Repeat: POST is not deduplicated.\n\nAuth: Session + manage_employee_reviews (licensed)",
    "requestBody": {
     "description": "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) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/ReviewCycleWrite"
       }
      }
     }
    },
    "responses": {
     "404": {
      "description": "\"Review cycle not found.\""
     },
     "409": {
      "description": "\"A closed cycle cannot be changed.\", \"Only a draft can be deleted. Close a launched cycle instead.\""
     },
     "default": {
      "description": "POST 201, PUT 200: { cycle:ReviewCycle }. DELETE 204. 400 \"A review cycle needs a name.\", \"A review needs at least one question.\"",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_reviews (licensed)",
    "x-permissions-named": [
     "manage_employee_reviews"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "ReviewCycle",
     "ReviewCycleWrite"
    ],
    "x-catalogue-group": "hr-goals-reviews-surveys"
   }
  },
  "/user-management/reviews/cycles/{id}/launch": {
   "post": {
    "operationId": "post_user_management_reviews_cycles_id_launch",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "Launch a cycle (close it at /cycles/:id/close).",
    "description": "Request, as the catalogue words it: No body, for the launch and for the close.\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Repeat: closing twice is refused with 409.\n\nAuth: Session + manage_employee_reviews (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ReviewCycle primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ cycle:ReviewCycle, added:integer (reviews created by this call), total:integer, withoutReviewer:integer }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "cycle": {
           "$ref": "#/components/schemas/ReviewCycle"
          },
          "added": {
           "type": "integer",
           "description": "reviews created by this call"
          },
          "total": {
           "type": "integer"
          },
          "withoutReviewer": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "when nobody active is in scope"
     },
     "404": {
      "description": "No further text in the catalogue."
     },
     "409": {
      "description": "\"A closed cycle cannot be launched again.\" Close: 200 { cycle:ReviewCycle } | \"Only an open cycle can be closed.\"",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_reviews (licensed)",
    "x-permissions-named": [
     "manage_employee_reviews"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "ReviewCycle"
    ],
    "x-catalogue-group": "hr-goals-reviews-surveys"
   }
  },
  "/user-management/reviews/cycles/{id}/progress": {
   "get": {
    "operationId": "get_user_management_reviews_cycles_id_progress",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "Who is where in a cycle.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + manage_employee_reviews (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ReviewCycle primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "cycle": {
           "$ref": "#/components/schemas/ReviewCycle"
          },
          "ratings": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "count": {
              "type": "integer"
             }
            },
            "x-keys-not-described": [
             "rating"
            ]
           }
          },
          "reviews": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "id": {
              "type": "integer"
             },
             "userId": {
              "type": "integer"
             },
             "person": {
              "type": "string"
             },
             "reviewerId": {
              "type": [
               "integer",
               "null"
              ]
             },
             "reviewer": {
              "type": [
               "string",
               "null"
              ]
             },
             "state": {
              "type": "string",
              "enum": [
               "with_person",
               "with_reviewer",
               "ready_to_share",
               "shared"
              ]
             }
            },
            "x-keys-not-described": [
             "selfSubmittedAt",
             "managerSubmittedAt",
             "sharedAt"
            ]
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_reviews (licensed)",
    "x-permissions-named": [
     "manage_employee_reviews"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "ReviewCycle"
    ],
    "x-catalogue-group": "hr-goals-reviews-surveys"
   }
  },
  "/user-management/reviews/cycles/{id}/calibration": {
   "get": {
    "operationId": "get_user_management_reviews_cycles_id_calibration",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "The calibration view (change one rating with PUT /reviews/:reviewId/calibration; release with POST /cycles/:id/release-ratings).",
    "description": "Request, as the catalogue words it: PUT /reviews/:reviewId/calibration: { rating (a whole number 1 to 5), note (required: the reviewer reads it) }\n\nReply, as the catalogue words it: 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 }.\n\nGuard: Repeat: releasing twice is safe; the second answer carries already: true and notifies nobody.\n\nAuth: Session + manage_employee_reviews (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: ReviewCycle primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "400": {
      "description": "\"That is already the rating.\" or a missing reason"
     },
     "403": {
      "description": "\"You cannot calibrate your own review.\""
     },
     "409": {
      "description": "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 }.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "default": {
      "description": "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 }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_reviews (licensed)",
    "x-permissions-named": [
     "manage_employee_reviews"
    ],
    "x-licensed": true,
    "x-catalogue-group": "hr-goals-reviews-surveys"
   }
  },
  "/user-management/reviews/{reviewId}/reviewer": {
   "put": {
    "operationId": "put_user_management_reviews_reviewId_reviewer",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "Assign or reassign the reviewer (reopen a submitted half at POST /:reviewId/reopen).",
    "description": "Request, as the catalogue words it: The ReviewerReassign object: { reviewerId }. Reopen: { part: 'self' | 'manager' }\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Repeat: not a no-op: naming the same reviewer again answers 200, clears anything that reviewer has written so far, and notifies them again\n\nAuth: Session + manage_employee_reviews (licensed)",
    "parameters": [
     {
      "name": "reviewId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: PerformanceReview primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The ReviewerReassign object: { reviewerId }. Reopen: { part: 'self' | 'manager' }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/ReviewerReassign"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok: true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"Choose a reviewer.\", \"A person cannot review themselves.\", \"That reviewer is not an active person.\", \"part must be 'self' or 'manager'.\""
     },
     "404": {
      "description": "\"Review not found.\""
     },
     "409": {
      "description": "\"This review has already been shared.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_reviews (licensed)",
    "x-permissions-named": [
     "manage_employee_reviews"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "ReviewerReassign"
    ],
    "x-catalogue-group": "hr-goals-reviews-surveys"
   }
  },
  "/user-management/reviews/{reviewId}": {
   "get": {
    "operationId": "get_user_management_reviews_reviewId",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "Read a review in full as HR. Each read is written to the audit log.",
    "description": "Reply, as the catalogue words it: 200 { review } | 403 \"Your own review is not shared with you yet.\" | 404 \"Review not found.\"\n\nAuth: Session + manage_employee_reviews (licensed)",
    "parameters": [
     {
      "name": "reviewId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: PerformanceReview primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ review }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "403": {
      "description": "\"Your own review is not shared with you yet.\""
     },
     "404": {
      "description": "\"Review not found.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_reviews (licensed)",
    "x-permissions-named": [
     "manage_employee_reviews"
    ],
    "x-licensed": true,
    "x-catalogue-group": "hr-goals-reviews-surveys"
   }
  },
  "/user-management/surveys/mine": {
   "get": {
    "operationId": "get_user_management_surveys_mine",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "The open surveys you are invited to.",
    "description": "Reply, as the catalogue words it: 200 { surveys:[ Survey ] }, each with responded:boolean; only open surveys you were invited to, most recently opened first; no paging.\n\nAuth: Session (licensed)",
    "responses": {
     "200": {
      "description": "{ surveys:[ Survey ] }, each with responded:boolean; only open surveys you were invited to, most recently opened first; no paging.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "surveys": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/Survey"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "Survey"
    ],
    "x-catalogue-group": "hr-goals-reviews-surveys"
   }
  },
  "/user-management/surveys/{id}/respond": {
   "post": {
    "operationId": "post_user_management_surveys_id_respond",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "Answer a survey, once. The answer sheet is stored without your identity.",
    "description": "Request, as the catalogue words it: { answers }: a whole number 1 to 5 for a scale question, 0 to 10 for an enps question, text for a text question.\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Repeat: refused with 409; a person answers once.\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Survey primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "{ answers }: a whole number 1 to 5 for a scale question, 0 to 10 for an enps question, text for a text question.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ ok: true }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"Answer at least one question.\" or a number outside its range"
     },
     "404": {
      "description": "\"Survey not found.\" (also when you were not invited)"
     },
     "409": {
      "description": "\"You have already answered this survey.\" or \"This survey is closed.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "hr-goals-reviews-surveys"
   }
  },
  "/user-management/surveys": {
   "get": {
    "operationId": "get_user_management_surveys",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "All surveys (the same question compared across closed surveys at /surveys/trends).",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + manage_employee_surveys (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "surveys": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/Survey"
           }
          },
          "defaultQuestions": {
           "type": "array",
           "items": {
            "type": "object"
           },
           "description": "in the shape of Survey.questions"
          }
         },
         "x-keys-not-described": [
          "minResponses"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_surveys (licensed)",
    "x-permissions-named": [
     "manage_employee_surveys"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Survey"
    ],
    "x-catalogue-group": "hr-goals-reviews-surveys"
   },
   "post": {
    "operationId": "post_user_management_surveys",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "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).",
    "description": "Request, as the catalogue words it: 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) }\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Repeat: POST and duplicate each create another draft.\n\nAuth: Session + manage_employee_surveys (licensed)",
    "requestBody": {
     "description": "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) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/SurveyWrite"
       }
      }
     }
    },
    "responses": {
     "404": {
      "description": "\"Survey not found.\""
     },
     "409": {
      "description": "\"A closed survey cannot be changed.\", \"Only a draft can be deleted. Close an open survey instead.\""
     },
     "default": {
      "description": "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",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_surveys (licensed)",
    "x-permissions-named": [
     "manage_employee_surveys"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Survey",
     "SurveyWrite"
    ],
    "x-catalogue-group": "hr-goals-reviews-surveys"
   }
  },
  "/user-management/surveys/{id}/open": {
   "post": {
    "operationId": "post_user_management_surveys_id_open",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "Open a survey to its audience (close it at /surveys/:id/close).",
    "description": "Request, as the catalogue words it: No body, for the open and for the close.\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Repeat: refused with 409; a survey opens once and closes once.\n\nAuth: Session + manage_employee_surveys (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Survey primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ survey:Survey, invited:integer }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "survey": {
           "$ref": "#/components/schemas/Survey"
          },
          "invited": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "when fewer than 5 people would be asked, or the closing date has passed"
     },
     "404": {
      "description": "No further text in the catalogue."
     },
     "409": {
      "description": "\"Only a draft survey can be opened.\" Close: 200 { survey:Survey } | \"Only an open survey can be closed.\"",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_surveys (licensed)",
    "x-permissions-named": [
     "manage_employee_surveys"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Survey"
    ],
    "x-catalogue-group": "hr-goals-reviews-surveys"
   }
  },
  "/user-management/surveys/{id}/results": {
   "get": {
    "operationId": "get_user_management_surveys_id_results",
    "tags": [
     "Goals, Review Cycles & Surveys"
    ],
    "summary": "The combined results of a closed survey. Never individual answers.",
    "description": "Reply, as the catalogue words it: 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.\"\n\nAuth: Session + manage_employee_surveys (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: Survey primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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.\"",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "survey": {
           "$ref": "#/components/schemas/Survey"
          },
          "results": {
           "type": "object",
           "properties": {
            "responses": {
             "type": "integer"
            },
            "invited": {
             "type": "integer"
            },
            "responseRate": {
             "type": [
              "integer",
              "null"
             ]
            },
            "withheld": {
             "type": "boolean"
            },
            "reason": {
             "type": "string",
             "enum": [
              "open",
              "too_few"
             ]
            },
            "questions": {
             "type": "array",
             "items": {
              "type": "object"
             }
            }
           },
           "x-keys-not-described": [
            "minResponses"
           ]
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_surveys (licensed)",
    "x-permissions-named": [
     "manage_employee_surveys"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "Survey"
    ],
    "x-catalogue-group": "hr-goals-reviews-surveys"
   }
  },
  "/user-management/training/mine": {
   "get": {
    "operationId": "get_user_management_training_mine",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Your own training, to do and done.",
    "description": "Reply, as the catalogue words it: 200 { assignments:[ TrainingAssignment ] }; no paging. Open items on a retired course are left out.\n\nAuth: Session (licensed)",
    "responses": {
     "200": {
      "description": "{ assignments:[ TrainingAssignment ] }; no paging. Open items on a retired course are left out.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "assignments": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/TrainingAssignment"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "TrainingAssignment"
    ],
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/assignments/{assignmentId}/complete": {
   "post": {
    "operationId": "post_user_management_training_assignments_assignmentId_complete",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Mark an assignment done. It must be your own, unless you hold manage_employee_training; a manager cannot complete training for a report.",
    "description": "Request, as the catalogue words it: 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.\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Repeat: safe; completing an item that is already done returns it and records nothing new.\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "assignmentId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: TrainingAssignment primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/TrainingComplete"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ assignment:TrainingAssignment }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "assignment": {
           "$ref": "#/components/schemas/TrainingAssignment"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"Confirm that you have read and understood it.\", \"Upload your certificate first.\", \"Answer the questions to finish this.\""
     },
     "404": {
      "description": "\"Assignment not found.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "TrainingAssignment",
     "TrainingComplete"
    ],
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/assignments/{assignmentId}/quiz": {
   "post": {
    "operationId": "post_user_management_training_assignments_assignmentId_quiz",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Answer the knowledge check of your own assignment.",
    "description": "Request, as the catalogue words it: { answers, draw? }\n\nReply, as the catalogue words it: 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.\n\nGuard: Repeat: after a pass, another call answers { passed: true, assignment } and records nothing.\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "assignmentId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: TrainingAssignment primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "{ answers, draw? }",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ passed:true, score:integer, assignment:TrainingAssignment } | { passed:false, score:integer, passMark:integer, triesLeft:integer|null (null when tries are not limited), wrongCount:integer, quiz:TrainingQuiz (the next try's questions) } | { passed:false, stale:true, quiz:TrainingQuiz } when the questions changed while you answered",
      "content": {
       "application/json": {
        "schema": {
         "anyOf": [
          {
           "type": "object",
           "properties": {
            "passed": {
             "type": "boolean"
            },
            "score": {
             "type": "integer"
            },
            "assignment": {
             "$ref": "#/components/schemas/TrainingAssignment"
            }
           }
          },
          {
           "type": "object",
           "properties": {
            "passed": {
             "type": "boolean"
            },
            "score": {
             "type": "integer"
            },
            "passMark": {
             "type": "integer"
            },
            "triesLeft": {
             "type": [
              "integer",
              "null"
             ],
             "description": "null when tries are not limited"
            },
            "wrongCount": {
             "type": "integer"
            },
            "quiz": {
             "allOf": [
              {
               "$ref": "#/components/schemas/TrainingQuiz"
              }
             ],
             "description": "the next try's questions"
            }
           }
          },
          {
           "type": "object",
           "properties": {
            "passed": {
             "type": "boolean"
            },
            "stale": {
             "type": "boolean"
            },
            "quiz": {
             "$ref": "#/components/schemas/TrainingQuiz"
            }
           }
          }
         ]
        }
       }
      }
     },
     "400": {
      "description": "\"This training item has no knowledge check.\""
     },
     "404": {
      "description": "No further text in the catalogue."
     },
     "429": {
      "description": "when today's tries are used up."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "TrainingQuiz",
     "TrainingAssignment"
    ],
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/assignments/{assignmentId}/certificate": {
   "put": {
    "operationId": "put_user_management_training_assignments_assignmentId_certificate",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Upload a certificate for training that asks for one (download with GET). Your own, or as HR.",
    "description": "Request, as the catalogue words it: The file itself as the raw request body: PDF, PNG or JPEG.\n\nReply, as the catalogue words it: 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.\"\n\nGuard: 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\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "assignmentId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: TrainingAssignment primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The file itself as the raw request body: PDF, PNG or JPEG.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ assignment:TrainingAssignment }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "assignment": {
           "$ref": "#/components/schemas/TrainingAssignment"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"This training does not ask for a certificate.\""
     },
     "404": {
      "description": "\"Assignment not found.\" | \"No certificate has been uploaded.\""
     },
     "409": {
      "description": "\"This training is already marked done.\""
     },
     "415": {
      "description": "for any other file type. GET: the file"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "TrainingAssignment"
    ],
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/people/{userId}": {
   "get": {
    "operationId": "get_user_management_training_people_userId",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "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.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "userId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: User primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "person": {
           "type": "object",
           "properties": {
            "id": {
             "type": "integer"
            },
            "name": {
             "type": "string"
            }
           }
          },
          "assignments": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/TrainingAssignment"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "TrainingAssignment"
    ],
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/team": {
   "get": {
    "operationId": "get_user_management_training_team",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "What is open and overdue for the people below you in the reporting line.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "people": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "userId": {
              "type": "integer"
             },
             "name": {
              "type": "string"
             },
             "department": {
              "type": [
               "string",
               "null"
              ]
             },
             "direct": {
              "type": "boolean"
             },
             "open": {
              "type": "integer"
             },
             "overdue": {
              "type": "integer"
             },
             "items": {
              "type": "array",
              "items": {
               "type": "object",
               "properties": {
                "assignmentId": {
                 "type": "integer"
                },
                "course": {
                 "type": "string"
                },
                "overdue": {
                 "type": "boolean"
                },
                "renewal": {
                 "type": "boolean"
                }
               },
               "x-keys-not-described": [
                "dueDate"
               ]
              }
             }
            }
           }
          },
          "summary": {
           "type": "object",
           "properties": {
            "people": {
             "type": "integer"
            },
            "open": {
             "type": "integer"
            },
            "overdue": {
             "type": "integer"
            }
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/courses": {
   "get": {
    "operationId": "get_user_management_training_courses",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "List courses (one course's knowledge check with its answers at /courses/:id/quiz).",
    "description": "Reply, as the catalogue words it: 200 { courses:[ TrainingCourse ] }, each with the counts assigned:integer, completed:integer and overdue:integer; courses in use first, then by title; everything, no paging.\n\nAuth: Session + manage_employee_training (licensed)",
    "responses": {
     "200": {
      "description": "{ courses:[ TrainingCourse ] }, each with the counts assigned:integer, completed:integer and overdue:integer; courses in use first, then by title; everything, no paging.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "courses": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/TrainingCourse"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "TrainingCourse"
    ],
    "x-catalogue-group": "training"
   },
   "post": {
    "operationId": "post_user_management_training_courses",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Create a course (change with PUT /courses/:id).",
    "description": "Request, as the catalogue words it: 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 }\n\nReply, as the catalogue words it: POST 201, PUT 200: { course:TrainingCourse } | 400 with the reason | 404 \"Training item not found.\"\n\nGuard: A course with a security topic must be redone at least every 12 months and cannot use proof 'none'. Repeat: POST is not deduplicated.\n\nAuth: Session + manage_employee_training (licensed)",
    "requestBody": {
     "description": "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 }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/TrainingCourseWrite"
       }
      }
     }
    },
    "responses": {
     "400": {
      "description": "with the reason"
     },
     "404": {
      "description": "\"Training item not found.\""
     },
     "default": {
      "description": "POST 201, PUT 200: { course:TrainingCourse }",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "TrainingCourse",
     "TrainingCourseWrite"
    ],
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/library": {
   "get": {
    "operationId": "get_user_management_training_library",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "The ready-made course library.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + manage_employee_training (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "videos": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "id": {
              "type": "string"
             },
             "title": {
              "type": "string"
             },
             "seconds": {
              "type": "integer"
             },
             "url": {
              "type": "string"
             }
            }
           }
          },
          "courses": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "key": {
              "type": "string"
             },
             "title": {
              "type": "string"
             },
             "description": {
              "type": "string"
             },
             "minutes": {
              "type": "integer"
             },
             "sections": {
              "type": "integer"
             },
             "questions": {
              "type": "integer"
             },
             "passMark": {
              "type": "integer"
             },
             "securityAwareness": {
              "type": "boolean"
             },
             "topic": {
              "type": "string"
             },
             "feeds": {
              "type": "integer"
             },
             "fills": {
              "type": "array"
             },
             "hasVideo": {
              "type": "boolean"
             },
             "added": {
              "type": "boolean"
             },
             "guided": {
              "type": "boolean"
             }
            }
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/library/{key}/adopt": {
   "post": {
    "operationId": "post_user_management_training_library_key_adopt",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Add a library course to your own list as an ordinary course: editable and not yet assigned.",
    "description": "Request, as the catalogue words it: The LibraryAdopt object: { replace?: true }\n\nReply, as the catalogue words it: 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\n\nGuard: 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\n\nAuth: Session + manage_employee_training (licensed)",
    "parameters": [
     {
      "name": "key",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "requestBody": {
     "description": "The LibraryAdopt object: { replace?: true }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/LibraryAdopt"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ course:TrainingCourse }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "course": {
           "$ref": "#/components/schemas/TrainingCourse"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "\"That course is not in the library.\""
     },
     "409": {
      "description": "when you already have a training item with that title and replace is not true"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "TrainingCourse",
     "LibraryAdopt"
    ],
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/courses/{id}/assign": {
   "post": {
    "operationId": "post_user_management_training_courses_id_assign",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Assign a course to people (the choices an audience can pick from at GET /training/audience-options).",
    "description": "Request, as the catalogue words it: 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) }\n\nReply, as the catalogue words it: 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)\n\nGuard: Repeat: safe. A person who already has the course is left exactly as they are; added counts only new assignments.\n\nAuth: Session + manage_employee_training (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: TrainingCourse primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/TrainingAssign"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ added:integer (new assignments), matched:integer (active people the request matched), delivered:integer (people a notice reached) }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "added": {
           "type": "integer",
           "description": "new assignments"
          },
          "matched": {
           "type": "integer",
           "description": "active people the request matched"
          },
          "delivered": {
           "type": "integer",
           "description": "people a notice reached"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"Choose a department.\", \"Choose at least one person.\", \"Nobody active matches that.\""
     },
     "404": {
      "description": "\"Training item not found.\" (also for a retired course)"
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "TrainingAssign"
    ],
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/courses/{id}/status": {
   "get": {
    "operationId": "get_user_management_training_courses_id_status",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Who has and has not done one course.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + manage_employee_training (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: TrainingCourse primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "course": {
           "$ref": "#/components/schemas/TrainingCourse"
          },
          "left": {
           "type": "integer"
          },
          "assignments": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/TrainingAssignment"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "TrainingCourse",
     "TrainingAssignment"
    ],
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/roster": {
   "get": {
    "operationId": "get_user_management_training_roster",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Every assignment across all courses. Add ?format=csv to download the same rows.",
    "description": "Request, as the catalogue words it: Query courseId?, department?, status?, format=csv.\n\nReply, as the catalogue words it: 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.\n\nAuth: Session + manage_employee_training (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "rows": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "assignmentId": {
              "type": "integer"
             },
             "courseId": {
              "type": "integer"
             },
             "course": {
              "type": "string"
             },
             "securityAwareness": {
              "type": "boolean"
             },
             "active": {
              "type": "boolean"
             },
             "userId": {
              "type": "integer"
             },
             "person": {
              "type": "string"
             },
             "email": {
              "type": "string"
             },
             "department": {
              "type": [
               "string",
               "null"
              ]
             },
             "manager": {
              "type": [
               "string",
               "null"
              ]
             },
             "state": {
              "type": "string",
              "enum": [
               "overdue",
               "open",
               "exempt",
               "completed"
              ]
             },
             "exemptUntil": {
              "type": [
               "string",
               "null"
              ],
              "format": "date"
             },
             "exemptReason": {
              "type": [
               "string",
               "null"
              ]
             },
             "dueDate": {
              "type": [
               "string",
               "null"
              ],
              "format": "date"
             },
             "completedAt": {
              "type": [
               "string",
               "null"
              ],
              "format": "date-time"
             },
             "lastCompletedAt": {
              "type": [
               "string",
               "null"
              ],
              "format": "date-time"
             },
             "expiresAt": {
              "type": [
               "string",
               "null"
              ],
              "format": "date"
             },
             "timesCompleted": {
              "type": "integer"
             },
             "markedByHr": {
              "type": "boolean"
             },
             "hasCertificate": {
              "type": "boolean"
             }
            }
           }
          },
          "truncated": {
           "type": "boolean"
          },
          "summary": {
           "type": "object",
           "x-keys-not-described": [
            "total",
            "overdue",
            "open",
            "exempt",
            "completed"
           ],
           "description": "integers"
          },
          "departments": {
           "type": "array",
           "items": {
            "type": "string"
           }
          },
          "dashboard": {
           "type": "object",
           "description": "the figures behind the page's charts; not typed here"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/assignments/batch": {
   "post": {
    "operationId": "post_user_management_training_assignments_batch",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Change the due date of, remind or remove a selection of assignments in one call (remove one with DELETE /assignments/:assignmentId).",
    "description": "Request, as the catalogue words it: 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=.\n\nReply, as the catalogue words it: 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.\"\n\nGuard: 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.\"\n\nAuth: Session + manage_employee_training (licensed)",
    "requestBody": {
     "description": "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=.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/TrainingBatchAction"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ action:string, done:integer, skipped:[{ id:integer, reason:string }] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "action": {
           "type": "string"
          },
          "done": {
           "type": "integer"
          },
          "skipped": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "id": {
              "type": "integer"
             },
             "reason": {
              "type": "string"
             }
            }
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"Choose at least one assignment.\", \"Too many in one go.\", \"Choose the new due date.\" DELETE: 204"
     },
     "404": {
      "description": "\"Assignment not found.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "TrainingBatchAction"
    ],
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/courses/{id}/import-completions": {
   "post": {
    "operationId": "post_user_management_training_courses_id_import_completions",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Record completions of one course that happened in another system (a file covering several courses at POST /training/import-completions).",
    "description": "Request, as the catalogue words it: 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? }.\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Repeat: safe; a row whose person already has a completion on or after that date is listed in skipped and nothing is recorded twice\n\nAuth: Session + manage_employee_training (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: TrainingCourse primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "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? }.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/CompletionsImport"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ imported:integer, skipped:[{ email:string, reason:string }] }; the file route answers { imported, items[], skipped, ambiguous } (not typed here)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "imported": {
           "type": "integer"
          },
          "skipped": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "email": {
              "type": "string"
             },
             "reason": {
              "type": "string"
             }
            }
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"There is nothing to import.\" or more than 5,000 rows"
     },
     "404": {
      "description": "\"Training item not found.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "CompletionRow",
     "CompletionsImport"
    ],
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/settings": {
   "get": {
    "operationId": "get_user_management_training_settings",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Read the reminder settings (save with PUT).",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + manage_employee_training (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "daysBefore": {
           "type": "integer"
          },
          "renewGraceDays": {
           "type": "integer"
          },
          "sendHourUtc": {
           "type": [
            "integer",
            "null"
           ]
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/exemption-suggestions": {
   "get": {
    "operationId": "get_user_management_training_exemption_suggestions",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "People on long approved leave who have open training.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + manage_employee_training (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "suggestions": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "userId": {
              "type": "integer"
             },
             "person": {
              "type": "string"
             },
             "email": {
              "type": "string"
             },
             "courseId": {
              "type": "integer"
             },
             "course": {
              "type": "string"
             }
            },
            "x-keys-not-described": [
             "dueDate",
             "leaveUntil"
            ]
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/courses/{id}/video": {
   "post": {
    "operationId": "post_user_management_training_courses_id_video",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Upload a video file for a course, .mp4 or .webm (remove with DELETE).",
    "description": "Request, as the catalogue words it: The video file itself as the raw request body: an .mp4 or a .webm file. No JSON body. DELETE: no body.\n\nReply, as the catalogue words it: 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 }.\n\nGuard: Repeat: a second upload replaces the first\n\nAuth: Session + manage_employee_training (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: TrainingCourse primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The video file itself as the raw request body: an .mp4 or a .webm file. No JSON body. DELETE: no body.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ course:TrainingCourse, sizeBytes:integer }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "course": {
           "$ref": "#/components/schemas/TrainingCourse"
          },
          "sizeBytes": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"The file is empty.\" or an interrupted upload"
     },
     "413": {
      "description": "when the video is over the size limit"
     },
     "415": {
      "description": "for a file that is not an .mp4 or a .webm video. DELETE: 200 { course:TrainingCourse }.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "TrainingCourse"
    ],
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/courses/{id}/video/file": {
   "get": {
    "operationId": "get_user_management_training_courses_id_video_file",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Watch a course's uploaded video, as HR or as someone the course is assigned to.",
    "description": "Reply, as the catalogue words it: 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.\"\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: TrainingCourse primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "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.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/courses/{id}/scorm": {
   "post": {
    "operationId": "post_user_management_training_courses_id_scorm",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Import a SCORM 1.2 package of one lesson (remove with DELETE).",
    "description": "Request, as the catalogue words it: The SCORM package itself, a .zip file of at most 50 MB, as the raw request body. No JSON body. DELETE: no body.\n\nReply, as the catalogue words it: 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 }.\n\nGuard: Repeat: a second import replaces the earlier package\n\nAuth: Session + manage_employee_training (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: TrainingCourse primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The SCORM package itself, a .zip file of at most 50 MB, as the raw request body. No JSON body. DELETE: no body.",
     "content": {
      "*/*": {
       "schema": {}
      }
     }
    },
    "responses": {
     "201": {
      "description": "{ course:TrainingCourse }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "course": {
           "$ref": "#/components/schemas/TrainingCourse"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"The file is empty.\", or a package that cannot be read"
     },
     "413": {
      "description": "when the .zip is over 50 MB. DELETE: 200 { course:TrainingCourse }.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "TrainingCourse"
    ],
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/courses/{id}/scorm/launch": {
   "get": {
    "operationId": "get_user_management_training_courses_id_scorm_launch",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Get a short-lived pass to open a course's SCORM lesson, as HR or as someone it is assigned to.",
    "description": "Reply, as the catalogue words it: 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.\"\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: TrainingCourse primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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.\"",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "token": {
           "type": "string"
          },
          "channel": {
           "type": "string"
          },
          "launch": {
           "type": "string"
          },
          "package": {
           "type": "object",
           "properties": {
            "title": {
             "type": [
              "string",
              "null"
             ]
            },
            "launch": {
             "type": "string"
            },
            "files": {
             "type": "integer"
            },
            "bytes": {
             "type": "integer"
            }
           },
           "x-keys-not-described": [
            "importedAt"
           ]
          }
         },
         "x-keys-not-described": [
          "expiresAt"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "training"
   }
  },
  "/training-content/{pass}/{path}": {
   "get": {
    "operationId": "get_training_content_pass",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "The files of a SCORM lesson, shown in a sandboxed frame. The pass in the path is the credential.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Launch pass (rate-limited)",
    "parameters": [
     {
      "name": "pass",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     },
     {
      "name": "path",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "A string by its use: the rest of the path.",
      "x-type-basis": "literal"
     }
    ],
    "responses": {
     "200": {
      "description": "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.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "namedInXAuth": []
     }
    ],
    "x-auth": "Launch pass (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/paths": {
   "get": {
    "operationId": "get_user_management_training_paths",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "List learning paths.",
    "description": "Reply, as the catalogue words it: 200 { paths:[ LearningPath ] } with people, finished and percent on each; paths in use first, then by title; everything, no paging.\n\nAuth: Session + manage_employee_training (licensed)",
    "responses": {
     "200": {
      "description": "{ paths:[ LearningPath ] } with people, finished and percent on each; paths in use first, then by title; everything, no paging.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "paths": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/LearningPath"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "LearningPath"
    ],
    "x-catalogue-group": "training"
   },
   "post": {
    "operationId": "post_user_management_training_paths",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Create a learning path (change with PUT /paths/:id, remove with DELETE /paths/:id).",
    "description": "Request, as the catalogue words it: 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 }\n\nReply, as the catalogue words it: POST 201, PUT 200: { path:LearningPath }. DELETE 204. 400 with the reason | 404 \"Learning path not found.\"\n\nGuard: Repeat: POST is not deduplicated.\n\nAuth: Session + manage_employee_training (licensed)",
    "requestBody": {
     "description": "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 }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/LearningPathWrite"
       }
      }
     }
    },
    "responses": {
     "404": {
      "description": "\"Learning path not found.\""
     },
     "default": {
      "description": "POST 201, PUT 200: { path:LearningPath }. DELETE 204. 400 with the reason",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "LearningPath",
     "LearningPathWrite"
    ],
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/paths/{id}/members": {
   "post": {
    "operationId": "post_user_management_training_paths_id_members",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Add one person or a selection to a path.",
    "description": "Request, as the catalogue words it: The PathMembersAdd object: { userIds[] } or { userId }; at most 500 people in one call.\n\nReply, as the catalogue words it: 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\n\nGuard: Repeat: safe; people already on the path are counted in already and not added again.\n\nAuth: Session + manage_employee_training (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: TrainingPath primary key.",
      "x-type-basis": "model"
     }
    ],
    "requestBody": {
     "description": "The PathMembersAdd object: { userIds[] } or { userId }; at most 500 people in one call.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/PathMembersAdd"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ added:integer, already:integer, notFound:integer (ids that matched no active person), assigned:integer, finished:integer, failed:integer }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "added": {
           "type": "integer"
          },
          "already": {
           "type": "integer"
          },
          "notFound": {
           "type": "integer",
           "description": "ids that matched no active person"
          },
          "assigned": {
           "type": "integer"
          },
          "finished": {
           "type": "integer"
          },
          "failed": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"Choose at least one person.\", \"This path is paused. Switch it back on before adding people.\", \"Nobody active matches that.\""
     },
     "404": {
      "description": "No further text in the catalogue."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "PathMembersAdd"
    ],
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/paths/{id}/progress": {
   "get": {
    "operationId": "get_user_management_training_paths_id_progress",
    "tags": [
     "Training: Courses, Learning Paths & Imports"
    ],
    "summary": "Per person: the step they are on, how many are done, when they started and finished.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + manage_employee_training (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: TrainingPath primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "path": {
           "$ref": "#/components/schemas/LearningPath"
          },
          "left": {
           "type": "integer"
          },
          "rows": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "userId": {
              "type": "integer"
             },
             "person": {
              "type": "string"
             },
             "department": {
              "type": [
               "string",
               "null"
              ]
             },
             "step": {
              "type": [
               "integer",
               "null"
              ]
             },
             "course": {
              "type": [
               "string",
               "null"
              ]
             },
             "overdue": {
              "type": "boolean"
             },
             "done": {
              "type": "integer"
             },
             "total": {
              "type": "integer"
             }
            },
            "x-keys-not-described": [
             "dueDate",
             "startedAt",
             "completedAt"
            ]
           }
          },
          "summary": {
           "type": "object",
           "properties": {
            "people": {
             "type": "integer"
            },
            "finished": {
             "type": "integer"
            },
            "overdue": {
             "type": "integer"
            }
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "LearningPath"
    ],
    "x-catalogue-group": "training"
   }
  },
  "/user-management/training/phishing/campaigns": {
   "get": {
    "operationId": "get_user_management_training_phishing_campaigns",
    "tags": [
     "Phishing Simulation"
    ],
    "summary": "List simulations (one at /campaigns/:id; the people in one at /campaigns/:id/people).",
    "description": "Reply, as the catalogue words it: 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 }] }.\n\nGuard: The list is the newest 200 simulations; no paging parameters are read. One that does not exist answers 404 \"That simulation does not exist.\"\n\nAuth: Session + manage_phishing_simulation (licensed)",
    "responses": {
     "200": {
      "description": "{ 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 }] }.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "campaigns": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/PhishingCampaign"
           }
          },
          "kpis": {
           "type": "object",
           "properties": {
            "campaignsSent": {
             "type": "integer"
            },
            "emailsSent": {
             "type": "integer"
            },
            "clickRate": {
             "type": [
              "integer",
              "null"
             ]
            },
            "reportRate": {
             "type": [
              "integer",
              "null"
             ]
            }
           }
          },
          "me": {
           "type": "object",
           "properties": {
            "id": {
             "type": "integer"
            },
            "isAdmin": {
             "type": "boolean"
            }
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_phishing_simulation (licensed)",
    "x-permissions-named": [
     "manage_phishing_simulation"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "PhishingCampaign"
    ],
    "x-catalogue-group": "phishing-simulation"
   },
   "post": {
    "operationId": "post_user_management_training_phishing_campaigns",
    "tags": [
     "Phishing Simulation"
    ],
    "summary": "Create a simulation (change with PUT /campaigns/:id, remove with DELETE /campaigns/:id).",
    "description": "Request, as the catalogue words it: 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) }\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Repeat: POST is not deduplicated.\n\nAuth: Session + manage_phishing_simulation (licensed)",
    "requestBody": {
     "description": "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) }",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/PhishingCampaignWrite"
       }
      }
     }
    },
    "responses": {
     "404": {
      "description": "No further text in the catalogue."
     },
     "409": {
      "description": "\"Only a draft can be changed. Send it back to draft first.\" or \"Only a draft can be deleted.\""
     },
     "default": {
      "description": "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.\"",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_phishing_simulation (licensed)",
    "x-permissions-named": [
     "manage_phishing_simulation"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "PhishingCampaign",
     "PhishingCampaignWrite"
    ],
    "x-catalogue-group": "phishing-simulation"
   }
  },
  "/user-management/training/phishing/templates": {
   "get": {
    "operationId": "get_user_management_training_phishing_templates",
    "tags": [
     "Phishing Simulation"
    ],
    "summary": "The practice email templates (the courses a simulation can point to at /phishing/courses).",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + manage_phishing_simulation (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "senderName": {
           "type": "string"
          },
          "linkReady": {
           "type": "boolean"
          },
          "mailReady": {
           "type": "boolean"
          },
          "templates": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "key": {
              "type": "string"
             },
             "name": {
              "type": "string"
             },
             "difficulty": {
              "type": "string"
             },
             "subject": {
              "type": "string"
             },
             "tells": {
              "type": "array",
              "items": {
               "type": "string"
              }
             },
             "previewHtml": {
              "type": "string"
             }
            }
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_phishing_simulation (licensed)",
    "x-permissions-named": [
     "manage_phishing_simulation"
    ],
    "x-licensed": true,
    "x-catalogue-group": "phishing-simulation"
   }
  },
  "/user-management/training/phishing/campaigns/{id}/submit": {
   "post": {
    "operationId": "post_user_management_training_phishing_campaigns_id_submit",
    "tags": [
     "Phishing Simulation"
    ],
    "summary": "Send a simulation for approval.",
    "description": "Request, as the catalogue words it: No body.\n\nReply, as the catalogue words it: 200 { campaign:PhishingCampaign }, now waiting for approval | 400 \"Pick at least one person before submitting.\" | 409 \"Only a draft can be submitted for approval.\"\n\nGuard: Repeat: refused with 409 once it is waiting for approval.\n\nAuth: Session + manage_phishing_simulation (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: PhishingCampaign primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ campaign:PhishingCampaign }, now waiting for approval",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "campaign": {
           "$ref": "#/components/schemas/PhishingCampaign"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "\"Pick at least one person before submitting.\""
     },
     "409": {
      "description": "\"Only a draft can be submitted for approval.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_phishing_simulation (licensed)",
    "x-permissions-named": [
     "manage_phishing_simulation"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "PhishingCampaign"
    ],
    "x-catalogue-group": "phishing-simulation"
   }
  },
  "/user-management/training/phishing/campaigns/{id}/approve": {
   "post": {
    "operationId": "post_user_management_training_phishing_campaigns_id_approve",
    "tags": [
     "Phishing Simulation"
    ],
    "summary": "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).",
    "description": "Request, as the catalogue words it: No body for the approve. Send back: { reason?:string }.\n\nReply, as the catalogue words it: 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.\n\nGuard: Repeat: a second approve is refused with 409.\n\nAuth: Session + manage_phishing_simulation (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: PhishingCampaign primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ campaign:PhishingCampaign }, now approved",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "campaign": {
           "$ref": "#/components/schemas/PhishingCampaign"
          }
         }
        }
       }
      }
     },
     "403": {
      "description": "with sod: true when you prepared it"
     },
     "409": {
      "description": "\"Only a simulation waiting for approval can be approved.\" Send back: 200 { campaign:PhishingCampaign } | unless it is waiting for approval, or approved and not yet sent.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_phishing_simulation (licensed)",
    "x-permissions-named": [
     "manage_phishing_simulation"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "PhishingCampaign"
    ],
    "x-catalogue-group": "phishing-simulation"
   }
  },
  "/user-management/training/phishing/campaigns/{id}/send": {
   "post": {
    "operationId": "post_user_management_training_phishing_campaigns_id_send",
    "tags": [
     "Phishing Simulation"
    ],
    "summary": "Send an approved simulation now (stop one at /campaigns/:id/cancel).",
    "description": "Request, as the catalogue words it: No body, for the send and for the cancel.\n\nReply, as the catalogue words it: 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.\n\nGuard: Repeat: refused with 409 while it is being sent and after it was sent.\n\nAuth: Session + manage_phishing_simulation (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: PhishingCampaign primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "202": {
      "description": "{ campaign:PhishingCampaign }; sending continues in the background",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "campaign": {
           "$ref": "#/components/schemas/PhishingCampaign"
          }
         }
        }
       }
      }
     },
     "409": {
      "description": "\"Only an approved simulation can be sent.\" or \"This simulation is already being sent.\" Cancel: 200 { campaign:PhishingCampaign } | when it is already sent or already cancelled.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_phishing_simulation (licensed)",
    "x-permissions-named": [
     "manage_phishing_simulation"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "PhishingCampaign"
    ],
    "x-catalogue-group": "phishing-simulation"
   }
  },
  "/user-management/training/phishing/campaigns/{id}/results": {
   "get": {
    "operationId": "get_user_management_training_phishing_campaigns_id_results",
    "tags": [
     "Phishing Simulation"
    ],
    "summary": "Per-person results. Each read is written to the audit log.",
    "description": "Reply, as the catalogue words it: 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).\n\nAuth: Session + manage_phishing_simulation (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Typed from the primary key of the record it names: PhishingCampaign primary key.",
      "x-type-basis": "model"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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).",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "campaign": {
           "$ref": "#/components/schemas/PhishingCampaign"
          },
          "rows": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "userId": {
              "type": "integer"
             },
             "person": {
              "type": "string"
             },
             "email": {
              "type": [
               "string",
               "null"
              ]
             },
             "department": {
              "type": [
               "string",
               "null"
              ]
             },
             "sendError": {
              "type": [
               "string",
               "null"
              ]
             },
             "outcome": {
              "type": "string",
              "enum": [
               "not_sent",
               "no_action",
               "clicked",
               "reported",
               "clicked_then_reported"
              ]
             }
            },
            "x-keys-not-described": [
             "sentAt",
             "clickedAt",
             "reportedAt"
            ]
           }
          },
          "pruned": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_phishing_simulation (licensed)",
    "x-permissions-named": [
     "manage_phishing_simulation"
    ],
    "x-licensed": true,
    "x-typed-objects": [
     "PhishingCampaign"
    ],
    "x-catalogue-group": "phishing-simulation"
   }
  },
  "/user-management/training/phishing/settings": {
   "get": {
    "operationId": "get_user_management_training_phishing_settings",
    "tags": [
     "Phishing Simulation"
    ],
    "summary": "Read the simulation settings (save with PUT).",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + manage_phishing_simulation (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "senderName": {
           "type": "string"
          },
          "linkBase": {
           "type": "string"
          },
          "retentionMonths": {
           "type": "integer"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_phishing_simulation (licensed)",
    "x-permissions-named": [
     "manage_phishing_simulation"
    ],
    "x-licensed": true,
    "x-catalogue-group": "phishing-simulation"
   }
  },
  "/user-management/training/phishing/report": {
   "post": {
    "operationId": "post_user_management_training_phishing_report",
    "tags": [
     "Phishing Simulation"
    ],
    "summary": "Report a suspicious email. Only your own practice email is matched.",
    "description": "Request, as the catalogue words it: 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) }.\n\nReply, as the catalogue words it: 200 { simulation:boolean }\n\nGuard: 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\n\nAuth: Session (licensed)",
    "requestBody": {
     "description": "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) }.",
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/PhishingReport"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ simulation:boolean }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "simulation": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-typed-objects": [
     "PhishingReport"
    ],
    "x-catalogue-group": "phishing-simulation"
   }
  },
  "/user-management/training/phishing/mine/{token}": {
   "get": {
    "operationId": "get_user_management_training_phishing_mine_token",
    "tags": [
     "Phishing Simulation"
    ],
    "summary": "After a click on your own practice email: the training it points to.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "token",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "simulation": {
           "type": "boolean"
          },
          "course": {
           "type": [
            "object",
            "null"
           ],
           "properties": {
            "id": {
             "type": "integer"
            },
            "title": {
             "type": "string"
            }
           }
          },
          "assignmentId": {
           "type": [
            "integer",
            "null"
           ]
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "phishing-simulation"
   }
  },
  "/public/phishing/l/{token}": {
   "get": {
    "operationId": "get_public_phishing_l_token",
    "tags": [
     "Phishing Simulation"
    ],
    "summary": "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.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Public (rate-limited)",
    "parameters": [
     {
      "name": "token",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "default": {
      "description": "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.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [],
    "x-auth": "Public (rate-limited)",
    "x-permissions-named": [],
    "x-catalogue-group": "phishing-simulation"
   }
  },
  "/compliance/reports/compliance/policy-templates": {
   "get": {
    "operationId": "get_compliance_reports_compliance_policy_templates",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "The policy template library, with which templates you have already added.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + view_reporting | manage_compliance | manage_employee_training (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "libraryVersion": {
           "type": "string"
          },
          "companyName": {
           "type": [
            "string",
            "null"
           ]
          },
          "templates": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "id": {
              "type": "string"
             },
             "title": {
              "type": "string"
             },
             "version": {
              "type": "string"
             },
             "summary": {
              "type": "string"
             },
             "ownerRole": {
              "type": "string"
             },
             "reviewCadenceDays": {
              "type": "integer"
             },
             "requiresAcknowledgment": {
              "type": "boolean"
             },
             "controlIds": {
              "type": "array",
              "items": {
               "type": "string"
              }
             },
             "fills": {
              "type": "array"
             },
             "ownText": {
              "type": "boolean"
             },
             "body": {
              "type": "string"
             },
             "adopted": {
              "type": [
               "object",
               "null"
              ],
              "properties": {
               "policyId": {
                "type": "string"
               },
               "version": {
                "type": "string"
               },
               "owner": {
                "type": "string"
               },
               "status": {
                "type": "string"
               },
               "reviewedBy": {
                "type": [
                 "string",
                 "null"
                ]
               },
               "draftedBy": {
                "type": [
                 "string",
                 "null"
                ]
               }
              },
              "x-keys-not-described": [
               "adoptedAt",
               "reviewedAt"
              ]
             }
            }
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance | manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "manage_employee_training",
     "view_reporting"
    ],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  },
  "/compliance/reports/compliance/policy-templates/adopt": {
   "post": {
    "operationId": "post_compliance_reports_compliance_policy_templates_adopt",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "Add one or several templates to your policy register.",
    "description": "Request, as the catalogue words it: { items: [{ templateId (required), owner, nextReviewDue?, requireAck? }] }\n\nReply, as the catalogue words it: 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.\n\nGuard: 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.\n\nAuth: Session + manage_compliance (licensed)",
    "requestBody": {
     "description": "{ items: [{ templateId (required), owner, nextReviewDue?, requireAck? }] }",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "items": {
          "type": "array",
          "items": {
           "type": "object",
           "x-keys-not-described": [
            "templateId",
            "owner",
            "nextReviewDue",
            "requireAck"
           ]
          }
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "with the same shape when every template was already added"
     },
     "201": {
      "description": "{ created[], skipped[], libraryVersion }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "created": {
           "type": "array"
          },
          "skipped": {
           "type": "array"
          }
         },
         "x-keys-not-described": [
          "libraryVersion"
         ]
        }
       }
      }
     },
     "400": {
      "description": "\"Pick at least one template to adopt.\" or an unknown templateId."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  },
  "/compliance/reports/compliance/policies/approve": {
   "post": {
    "operationId": "post_compliance_reports_compliance_policies_approve",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "Approve or re-approve one or several policies. The service allows the policy's owner or a holder of manage_compliance.",
    "description": "Request, as the catalogue words it: { ids[] } (or { id })\n\nReply, as the catalogue words it: 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[] }.\n\nGuard: 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.\n\nAuth: Session (licensed)",
    "requestBody": {
     "description": "{ ids[] } (or { id })",
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "ids": {
          "type": "array"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ 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[] }.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  },
  "/compliance/reports/compliance/policies/{id}/document": {
   "get": {
    "operationId": "get_compliance_reports_compliance_policies_id_document",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "The text of a policy, for any signed-in person.",
    "description": "Reply, as the catalogue words it: 404 \"Policy not found\" for an unknown id.\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "404": {
      "description": "\"Policy not found\" for an unknown id."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  },
  "/compliance/reports/compliance/policies/ack/mine": {
   "get": {
    "operationId": "get_compliance_reports_compliance_policies_ack_mine",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "The policies you are asked to sign, and the ones you have signed.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "pending": {
           "type": "array",
           "items": {
            "type": "object"
           }
          },
          "acknowledged": {
           "type": "array",
           "items": {
            "type": "object"
           }
          },
          "exempt": {
           "type": "array",
           "items": {
            "type": "object"
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  },
  "/compliance/reports/compliance/policies/{id}/sign-document": {
   "get": {
    "operationId": "get_compliance_reports_compliance_policies_id_sign_document",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "What you sign: the policy text cut into sections, each with a fingerprint.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "the document with contentHash and sections[{ n, heading, hash }]",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "404": {
      "description": "\"That policy is not available for signing.\" for a draft, or a policy that does not ask for signatures."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  },
  "/compliance/reports/compliance/policies/{id}/sign-progress": {
   "put": {
    "operationId": "put_compliance_reports_compliance_policies_id_sign_progress",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "Save which sections you have confirmed so far (read it back with GET).",
    "description": "Request, as the catalogue words it: { contentHash (from sign-document), sections[] (the hashes confirmed so far) }. GET takes ?contentHash=.\n\nReply, as the catalogue words it: 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.\"\n\nGuard: Repeat: safe; the list sent replaces the saved one.\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "requestBody": {
     "description": "{ contentHash (from sign-document), sections[] (the hashes confirmed so far) }. GET takes ?contentHash=.",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ sections[], openedAt }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "sections": {
           "type": "array"
          }
         },
         "x-keys-not-described": [
          "openedAt"
         ]
        }
       }
      }
     },
     "404": {
      "description": "\"That policy is not available for signing.\""
     },
     "409": {
      "description": "\"This policy changed while you were reading it. Reload and read the current text.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  },
  "/compliance/reports/compliance/policies/{id}/signed-document": {
   "get": {
    "operationId": "get_compliance_reports_compliance_policies_id_signed_document",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "The text you signed. Only ever your own signature (your signed copy as a PDF at /policies/:id/signed-copy.pdf).",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  },
  "/compliance/reports/compliance/policies/ack/team": {
   "get": {
    "operationId": "get_compliance_reports_compliance_policies_ack_team",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "What the people below you in the reporting line still have to sign.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "people": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "userId": {
              "type": "integer"
             },
             "name": {
              "type": "string"
             },
             "department": {
              "type": [
               "string",
               "null"
              ]
             },
             "direct": {
              "type": "boolean"
             },
             "open": {
              "type": "integer"
             },
             "overdue": {
              "type": "integer"
             },
             "items": {
              "type": "array",
              "items": {
               "type": "object",
               "properties": {
                "policyId": {
                 "type": "string"
                },
                "policy": {
                 "type": "string"
                },
                "version": {
                 "type": "string"
                },
                "overdue": {
                 "type": "boolean"
                }
               },
               "x-keys-not-described": [
                "dueBy"
               ]
              }
             }
            }
           }
          },
          "summary": {
           "type": "object",
           "properties": {
            "people": {
             "type": "integer"
            },
            "open": {
             "type": "integer"
            },
            "overdue": {
             "type": "integer"
            }
           }
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  },
  "/compliance/reports/compliance/policies/ack/status": {
   "get": {
    "operationId": "get_compliance_reports_compliance_policies_ack_status",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "Who has and has not signed, across policies.",
    "description": "Request, as the catalogue words it: Query policyId?, department?, state? (signed | due | overdue | exempt, or open for due and overdue together).\n\nReply, as the catalogue words it: 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.\n\nGuard: Each read is written to the audit log.\n\nAuth: Session + manage_compliance | manage_employee_training (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "rows": {
           "type": "array"
          },
          "departments": {
           "type": "array"
          }
         },
         "x-keys-not-described": [
          "counts",
          "policies"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance | manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  },
  "/compliance/reports/compliance/policies/ack/remind": {
   "post": {
    "operationId": "post_compliance_reports_compliance_policies_ack_remind",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "Remind the people who still owe a signature now.",
    "description": "Request, as the catalogue words it: { policyId? } (without it: every policy that still has open signatures)\n\nReply, as the catalogue words it: 200 { owed, reached, failed, policies[] } | 404 \"That policy does not need signing.\"\n\nGuard: Repeat: not deduplicated; every call sends the reminder again.\n\nAuth: Session + manage_compliance | manage_employee_training (licensed)",
    "requestBody": {
     "description": "{ policyId? } (without it: every policy that still has open signatures)",
     "content": {
      "application/json": {
       "schema": {}
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ owed, reached, failed, policies[] }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "policies": {
           "type": "array"
          }
         },
         "x-keys-not-described": [
          "owed",
          "reached",
          "failed"
         ]
        }
       }
      }
     },
     "404": {
      "description": "\"That policy does not need signing.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance | manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  },
  "/compliance/reports/compliance/policies/{id}/ack/roster": {
   "get": {
    "operationId": "get_compliance_reports_compliance_policies_id_ack_roster",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "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).",
    "description": "Request, as the catalogue words it: Query ?asOf=yyyy-mm-dd for the roster as it stood at the end of that day.\n\nReply, as the catalogue words it: 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.\n\nGuard: Each read is written to the audit log.\n\nAuth: Session + manage_compliance (licensed)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "{ policy, people[], asOf }",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "people": {
           "type": "array"
          }
         },
         "x-keys-not-described": [
          "policy",
          "asOf"
         ]
        }
       }
      }
     },
     "400": {
      "description": "\"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."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  },
  "/compliance/reports/compliance/people/evidence.pdf": {
   "get": {
    "operationId": "get_compliance_reports_compliance_people_evidence_pdf",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "Everything on record for one person, by ?email= or ?userId=, as a signed PDF.",
    "description": "Reply, as the catalogue words it: 200 a PDF, sent with headers X-Signature-Status and X-Content-SHA256.\n\nGuard: Each export is written to the audit log with the person it was about.\n\nAuth: Session + manage_compliance (licensed)",
    "responses": {
     "200": {
      "description": "a PDF, sent with headers X-Signature-Status and X-Content-SHA256."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance"
    ],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  },
  "/compliance/reports/compliance/training/{courseId}/my-certificate.pdf": {
   "get": {
    "operationId": "get_compliance_reports_compliance_training_courseId_my_certificate_pdf",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "Your own certificate of completion for a course.",
    "description": "Reply, as the catalogue words it: 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.\"\n\nAuth: Session (licensed)",
    "parameters": [
     {
      "name": "courseId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "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.\""
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  },
  "/compliance/reports/compliance/training/{courseId}/roster.pdf": {
   "get": {
    "operationId": "get_compliance_reports_compliance_training_courseId_roster_pdf",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "The signed roster of one course.",
    "description": "Request, as the catalogue words it: Query ?asOf=yyyy-mm-dd\n\nReply, as the catalogue words it: 200 a PDF, sent with headers X-Signature-Status and X-Content-SHA256 | 400 for a bad asOf.\n\nAuth: Session + manage_compliance | manage_employee_training (licensed)",
    "parameters": [
     {
      "name": "courseId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "UNKNOWN type: the catalogue has no ground for a narrower type than string, which is what the value is in the path.",
      "x-type-basis": "unknown"
     }
    ],
    "responses": {
     "200": {
      "description": "a PDF, sent with headers X-Signature-Status and X-Content-SHA256"
     },
     "400": {
      "description": "for a bad asOf."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + manage_compliance | manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "manage_employee_training"
    ],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  },
  "/compliance/reports/compliance/exemptions": {
   "get": {
    "operationId": "get_compliance_reports_compliance_exemptions",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "Recorded exemptions (record one with POST; end one at POST /exemptions/:exemptionId/end). The service allows a compliance manager, or HR for training.",
    "description": "Request, as the catalogue words it: 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) }\n\nReply, as the catalogue words it: 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).\n\nGuard: 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.\n\nAuth: Session (licensed)",
    "responses": {
     "400": {
      "description": "with the field at fault"
     },
     "403": {
      "description": "when you may not record that kind, or \"You cannot exempt yourself\""
     },
     "404": {
      "description": "\"Policy not found.\" or \"Training item not found.\" | \"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)."
     },
     "409": {
      "description": "\"This person is already exempt until ...\". End: 200 the exemption"
     },
     "default": {
      "description": "GET 200 { exemptions[], reasons[] }. POST 201 the exemption",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  },
  "/compliance/reports/compliance/evidence-retention": {
   "get": {
    "operationId": "get_compliance_reports_compliance_evidence_retention",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "How long signatures and completions are kept (change with PUT, manage_compliance). The same periods in plain words at /my-data-notice.",
    "description": "Request, as the catalogue words it: PUT: { signatureMonths } required: 0 keeps signatures indefinitely, otherwise a whole number between min and max.\n\nReply, as the catalogue words it: 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.\n\nGuard: PUT is written to the audit log at warning level with the before and after. Repeat: safe.\n\nAuth: Session (licensed)",
    "responses": {
     "200": {
      "description": "{ 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",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "signatureMonths": {
           "type": "integer",
           "description": "0 when signatures are kept with no end date"
          },
          "trainingMonths": {
           "type": "integer",
           "description": "the HR retention setting; 0 when it is off"
          }
         },
         "x-keys-not-described": [
          "min",
          "max"
         ]
        }
       }
      }
     },
     "400": {
      "description": "for any other value."
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session (licensed)",
    "x-permissions-named": [],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  },
  "/compliance/reports/compliance/policies/ack/coverage": {
   "get": {
    "operationId": "get_compliance_reports_compliance_policies_ack_coverage",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "Signing coverage as a combined figure.",
    "description": "Reply, as the catalogue words it: 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.\n\nAuth: Session + view_reporting | manage_compliance (licensed)",
    "responses": {
     "200": {
      "description": "{ 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.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "policies": {
           "type": "array"
          }
         },
         "x-keys-not-described": [
          "total"
         ]
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "view_reporting"
    ],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  },
  "/compliance/reports/compliance/evidence-map": {
   "get": {
    "operationId": "get_compliance_reports_compliance_evidence_map",
    "tags": [
     "Policy Signing & People Evidence"
    ],
    "summary": "Which controls training completions and policy signatures feed. A Compliance Hub route: it needs the Compliance Hub on your plan (Advanced).",
    "description": "Reply, as the catalogue words it: 200 { topics[], training[], policySigning[] }; each control as { id, title, frameworks[], topics[] }. Not paged.\n\nAuth: Session + view_reporting | manage_compliance | manage_employee_training (licensed)",
    "responses": {
     "200": {
      "description": "{ topics[], training[], policySigning[] }; each control as { id, title, frameworks[], topics[] }. Not paged.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "topics": {
           "type": "array"
          },
          "training": {
           "type": "array"
          },
          "policySigning": {
           "type": "array"
          }
         }
        }
       }
      }
     }
    },
    "security": [
     {
      "sessionCookie": []
     }
    ],
    "x-auth": "Session + view_reporting | manage_compliance | manage_employee_training (licensed)",
    "x-permissions-named": [
     "manage_compliance",
     "manage_employee_training",
     "view_reporting"
    ],
    "x-licensed": true,
    "x-catalogue-group": "policy-signing"
   }
  }
 },
 "components": {
  "securitySchemes": {
   "sessionCookie": {
    "type": "apiKey",
    "in": "cookie",
    "name": "__Host-token",
    "description": "The signed-in session cookie."
   },
   "scimBearer": {
    "type": "http",
    "scheme": "bearer",
    "description": "The SCIM bearer token."
   },
   "namedInXAuth": {
    "type": "apiKey",
    "in": "header",
    "name": "x-see-x-auth",
    "description": "A placeholder, not a real header. The operation needs the credential its x-auth field names (a signed request, a shared secret, an enrolment token or code, a device signature, a share-link or auditor-link token, or a launch pass). The catalogue does not state how each one is sent."
   }
  },
  "schemas": {
   "Error": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "error": {
      "type": "string"
     },
     "message": {
      "type": "string"
     }
    }
   },
   "OkResponse": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "ok": {
      "type": "boolean"
     }
    }
   },
   "User": {
    "description": "A directory user. Credential material (password hash, MFA secrets, recovery codes, invite token) is never returned.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "firstName": {
      "type": [
       "string",
       "null"
      ]
     },
     "lastName": {
      "type": [
       "string",
       "null"
      ]
     },
     "email": {
      "type": "string",
      "format": "email"
     },
     "sAMAccountName": {
      "type": [
       "string",
       "null"
      ],
      "description": "Login / directory account name (unique)."
     },
     "position": {
      "type": [
       "string",
       "null"
      ],
      "description": "Job title."
     },
     "department": {
      "type": [
       "string",
       "null"
      ]
     },
     "status": {
      "type": "string",
      "description": "e.g. active, inactive, disabled, pending, offboarded, deleted"
     },
     "isPrimaryAdmin": {
      "type": "boolean",
      "readOnly": true
     },
     "source": {
      "type": "string",
      "description": "local or the directory it syncs from"
     },
     "sourceProviderId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "photoUrl": {
      "type": [
       "string",
       "null"
      ]
     },
     "managerId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "externalId": {
      "type": [
       "string",
       "null"
      ]
     },
     "employeeId": {
      "type": [
       "string",
       "null"
      ]
     },
     "phoneNumber": {
      "type": [
       "string",
       "null"
      ]
     },
     "personalEmail": {
      "type": [
       "string",
       "null"
      ]
     },
     "attributes": {
      "type": "object",
      "description": "Custom directory attributes."
     },
     "mfaEnabled": {
      "type": "boolean",
      "readOnly": true
     },
     "mfaEnabledAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "mustChangePassword": {
      "type": "boolean",
      "readOnly": true
     },
     "passwordLastChanged": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "personaId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "Persona (role template / bill of materials)."
     },
     "lastActiveAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "terminationDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "description": "Scheduled last working day; null cancels a scheduled offboarding."
     },
     "inviteExpiresAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "invitedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "failedLoginAttempts": {
      "type": "integer",
      "readOnly": true
     },
     "lockoutUntil": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "sessionsInvalidatedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "Groups": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/GroupSummary"
      },
      "readOnly": true
     },
     "Roles": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/RoleSummary"
      },
      "readOnly": true
     },
     "Manager": {
      "anyOf": [
       {
        "$ref": "#/components/schemas/UserRef"
       },
       {
        "type": "null"
       }
      ],
      "readOnly": true
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "UserCard": {
    "description": "Minimal picker view of a user (returned to callers without directory permissions).",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "firstName": {
      "type": [
       "string",
       "null"
      ]
     },
     "lastName": {
      "type": [
       "string",
       "null"
      ]
     },
     "email": {
      "type": "string",
      "format": "email"
     },
     "position": {
      "type": [
       "string",
       "null"
      ]
     },
     "department": {
      "type": [
       "string",
       "null"
      ]
     },
     "photoUrl": {
      "type": [
       "string",
       "null"
      ]
     }
    }
   },
   "UserRef": {
    "description": "A labelled user reference.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "firstName": {
      "type": [
       "string",
       "null"
      ]
     },
     "lastName": {
      "type": [
       "string",
       "null"
      ]
     },
     "email": {
      "type": "string",
      "format": "email"
     }
    }
   },
   "UserPage": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "count": {
      "type": "integer",
      "description": "Total matching users."
     },
     "rows": {
      "x-catalogue-type": "(User | UserCard)[]"
     }
    }
   },
   "UserCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "firstName": {
      "type": "string"
     },
     "lastName": {
      "type": "string"
     },
     "email": {
      "type": "string",
      "format": "email"
     },
     "sAMAccountName": {
      "type": "string"
     },
     "position": {
      "type": "string"
     },
     "department": {
      "type": "string"
     },
     "managerId": {
      "type": "integer"
     },
     "personaId": {
      "type": "integer"
     },
     "status": {
      "type": "string"
     },
     "source": {
      "type": "string"
     },
     "employeeId": {
      "type": "string"
     },
     "phoneNumber": {
      "type": "string"
     },
     "attributes": {
      "type": "object"
     },
     "roleIds": {
      "type": "array",
      "items": {
       "type": "integer"
      }
     },
     "groupIds": {
      "type": "array",
      "items": {
       "type": "integer"
      }
     },
     "sendInvite": {
      "type": "boolean",
      "description": "Email a set-password link (recommended for API use)."
     }
    },
    "required": [
     "email"
    ]
   },
   "UserUpdate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "firstName": {
      "type": "string"
     },
     "lastName": {
      "type": "string"
     },
     "email": {
      "type": "string",
      "format": "email"
     },
     "sAMAccountName": {
      "type": "string"
     },
     "position": {
      "type": "string"
     },
     "department": {
      "type": "string"
     },
     "managerId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "personaId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "photoUrl": {
      "type": [
       "string",
       "null"
      ]
     },
     "employeeId": {
      "type": [
       "string",
       "null"
      ]
     },
     "phoneNumber": {
      "type": [
       "string",
       "null"
      ]
     },
     "terminationDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "status": {
      "type": "string",
      "description": "Non-terminal statuses only (use Offboard to terminate)."
     },
     "roleIds": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "Replaces the user's roles (requires manage_roles)."
     },
     "groupIds": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "Replaces the user's groups (requires manage_groups)."
     }
    }
   },
   "UserBulkUpdate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "userIds": {
      "type": "array",
      "items": {
       "type": "integer"
      }
     },
     "action": {
      "type": "string",
      "enum": [
       "setManager",
       "setDepartment",
       "setPersona",
       "setStatus",
       "addGroup",
       "removeGroup",
       "addRole",
       "removeRole"
      ]
     },
     "value": {
      "description": "Action argument (manager id, department, persona id, status in active|inactive|disabled, group/role id)."
     }
    },
    "required": [
     "userIds",
     "action"
    ]
   },
   "Group": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "name": {
      "type": "string"
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     },
     "source": {
      "type": "string"
     },
     "externalId": {
      "type": [
       "string",
       "null"
      ]
     },
     "sourceProviderId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "requiresMfa": {
      "type": "boolean",
      "description": "Legacy mirror of mfaPolicy = require."
     },
     "mfaPolicy": {
      "type": "string",
      "enum": [
       "inherit",
       "require",
       "exempt"
      ]
     },
     "mfaExemptUntil": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "mfaExemptReason": {
      "type": [
       "string",
       "null"
      ]
     },
     "Users": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "readOnly": true,
      "description": "Member ids only."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "GroupSummary": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "name": {
      "type": "string"
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     }
    }
   },
   "GroupWrite": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string"
     },
     "description": {
      "type": "string"
     },
     "userIds": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "Replaces the membership."
     },
     "mfaPolicy": {
      "type": "string",
      "enum": [
       "inherit",
       "require",
       "exempt"
      ],
      "description": "Requires manage_security."
     },
     "mfaExemptUntil": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "description": "Requires manage_security."
     },
     "mfaExemptReason": {
      "type": "string",
      "description": "Requires manage_security."
     }
    }
   },
   "Role": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "name": {
      "type": "string"
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     },
     "Permissions": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/Permission"
      },
      "readOnly": true
     },
     "Users": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "readOnly": true,
      "description": "Member ids only."
     },
     "Groups": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "readOnly": true,
      "description": "Groups the role is dynamically bound to."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "RoleSummary": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "name": {
      "type": "string"
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     }
    }
   },
   "RoleWrite": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string"
     },
     "description": {
      "type": "string"
     },
     "userIds": {
      "type": "array",
      "items": {
       "type": "integer"
      }
     },
     "permissionIds": {
      "type": "array",
      "items": {
       "type": "integer"
      }
     },
     "groupIds": {
      "type": "array",
      "items": {
       "type": "integer"
      }
     }
    }
   },
   "Permission": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "name": {
      "type": "string"
     },
     "module": {
      "type": [
       "string",
       "null"
      ]
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     },
     "Roles": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/RoleSummary"
      },
      "readOnly": true
     },
     "Users": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "readOnly": true
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "WorkflowNode": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "string"
     },
     "type": {
      "type": "string"
     },
     "data": {
      "type": "object"
     }
    }
   },
   "Workflow": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "name": {
      "type": "string"
     },
     "version": {
      "type": "integer",
      "readOnly": true
     },
     "isCurrent": {
      "type": "boolean",
      "readOnly": true
     },
     "type": {
      "type": "string",
      "description": "onboarding | offboarding | custom type"
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     },
     "createdBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "approvedBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "approvedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "isActive": {
      "type": "boolean",
      "readOnly": true
     },
     "isDefault": {
      "type": "boolean",
      "readOnly": true
     },
     "automationMode": {
      "type": "string",
      "enum": [
       "manual",
       "provisioning",
       "auto"
      ]
     },
     "status": {
      "type": "string",
      "description": "draft | pending | approved | rejected"
     },
     "rejectionReason": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "simulationRun": {
      "type": "boolean",
      "readOnly": true
     },
     "personaOverrides": {
      "type": "object",
      "description": "Per-persona exclusions: { \"<personaId>\": { excluded: [...] } }"
     },
     "nodes": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/WorkflowNode"
      }
     },
     "edges": {
      "type": "array",
      "items": {
       "type": "object"
      }
     },
     "purgeable": {
      "type": "boolean",
      "readOnly": true,
      "description": "Whether this never-used draft may be permanently deleted."
     },
     "deletedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "WorkflowWrite": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string"
     },
     "type": {
      "type": "string"
     },
     "description": {
      "type": "string"
     },
     "status": {
      "type": "string",
      "enum": [
       "draft",
       "pending"
      ],
      "description": "Submit for approval with pending."
     },
     "automationMode": {
      "type": "string",
      "enum": [
       "manual",
       "provisioning",
       "auto"
      ]
     },
     "personaOverrides": {
      "type": "object"
     },
     "nodes": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/WorkflowNode"
      }
     },
     "edges": {
      "type": "array",
      "items": {
       "type": "object"
      }
     }
    }
   },
   "WorkflowRun": {
    "description": "A run of a workflow (a \"task\").",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "workflowId": {
      "type": "integer"
     },
     "status": {
      "type": "string"
     },
     "title": {
      "type": "string"
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     },
     "currentStep": {
      "type": "integer"
     },
     "assignedTo": {
      "type": [
       "string",
       "null"
      ],
      "description": "Queue that owns the current step."
     },
     "assignedToUserId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The one person who owns the step, when person-owned."
     },
     "data": {
      "type": "object",
      "description": "Run payload (temporary credentials are never included)."
     },
     "createdBy": {
      "type": [
       "string",
       "null"
      ]
     },
     "userId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The subject user of the run."
     },
     "sourceCandidateId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "startDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "dueDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "nodes": {
      "type": [
       "array",
       "null"
      ],
      "items": {
       "$ref": "#/components/schemas/WorkflowNode"
      },
      "readOnly": true,
      "description": "Built server-side. Lists carry id + label/type only."
     },
     "edges": {
      "type": [
       "array",
       "null"
      ],
      "items": {
       "type": "object"
      },
      "readOnly": true,
      "description": "Omitted in lists."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "WorkflowRunPage": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "rows": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/WorkflowRun"
      }
     },
     "total": {
      "type": "integer"
     }
    }
   },
   "WorkflowRunCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "workflowId": {
      "type": "integer"
     },
     "title": {
      "type": "string"
     },
     "description": {
      "type": "string"
     },
     "status": {
      "type": "string"
     },
     "assignedTo": {
      "type": "string"
     },
     "currentStep": {
      "type": "integer"
     },
     "data": {
      "type": "object"
     },
     "userId": {
      "type": "integer"
     },
     "sourceCandidateId": {
      "type": "integer"
     },
     "startDate": {
      "type": "string",
      "format": "date"
     },
     "dueDate": {
      "type": "string",
      "format": "date"
     }
    },
    "required": [
     "workflowId",
     "title"
    ]
   },
   "WorkflowRunUpdate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "status": {
      "type": "string"
     },
     "assignedTo": {
      "type": "string"
     },
     "data": {
      "type": "object"
     },
     "currentStep": {
      "type": "integer"
     }
    }
   },
   "Device": {
    "description": "A device (read-only view). The device's agent key is never returned.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "name": {
      "type": "string"
     },
     "type": {
      "type": [
       "string",
       "null"
      ]
     },
     "serialNumber": {
      "type": [
       "string",
       "null"
      ]
     },
     "status": {
      "type": "string"
     },
     "manufacturer": {
      "type": [
       "string",
       "null"
      ]
     },
     "userId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "assetTag": {
      "type": [
       "string",
       "null"
      ]
     },
     "department": {
      "type": [
       "string",
       "null"
      ]
     },
     "location": {
      "type": [
       "string",
       "null"
      ]
     },
     "costCenter": {
      "type": [
       "string",
       "null"
      ]
     },
     "customFields": {
      "type": [
       "object",
       "null"
      ]
     },
     "platform": {
      "type": [
       "string",
       "null"
      ],
      "description": "macOS | linux | windows"
     },
     "mdmUdid": {
      "type": [
       "string",
       "null"
      ]
     },
     "pendingCommand": {
      "type": [
       "string",
       "null"
      ]
     },
     "reEnrollAllowed": {
      "type": "boolean"
     },
     "enrollmentApproved": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "false = pending approval; null = grandfathered/trusted."
     },
     "retiredAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "retiredReason": {
      "type": [
       "string",
       "null"
      ]
     },
     "hostname": {
      "type": [
       "string",
       "null"
      ]
     },
     "osVersion": {
      "type": [
       "string",
       "null"
      ]
     },
     "osBuild": {
      "type": [
       "string",
       "null"
      ]
     },
     "architecture": {
      "type": [
       "string",
       "null"
      ]
     },
     "chip": {
      "type": [
       "string",
       "null"
      ]
     },
     "modelName": {
      "type": [
       "string",
       "null"
      ]
     },
     "cpuCores": {
      "type": [
       "integer",
       "null"
      ]
     },
     "memoryBytes": {
      "type": [
       "integer",
       "null"
      ]
     },
     "diskTotalBytes": {
      "type": [
       "integer",
       "null"
      ]
     },
     "diskFreeBytes": {
      "type": [
       "integer",
       "null"
      ]
     },
     "osUpdateAvailable": {
      "type": "boolean"
     },
     "agentVersion": {
      "type": [
       "string",
       "null"
      ]
     },
     "agentVersionPin": {
      "type": [
       "string",
       "null"
      ]
     },
     "loggedInUser": {
      "type": [
       "string",
       "null"
      ]
     },
     "enrolledAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "lastCheckinAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "lastCheckinIp": {
      "type": [
       "string",
       "null"
      ]
     },
     "inventory": {
      "type": [
       "object",
       "null"
      ],
      "description": "Raw agent/MDM inventory."
     },
     "Owner": {
      "anyOf": [
       {
        "$ref": "#/components/schemas/UserRef"
       },
       {
        "type": "null"
       }
      ]
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "DeviceFleetPage": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "items": {
      "type": "array",
      "items": {
       "type": "object"
      }
     },
     "total": {
      "type": "integer"
     },
     "page": {
      "type": "integer"
     },
     "pageSize": {
      "type": "integer"
     },
     "kpis": {
      "type": "object"
     },
     "staleDays": {
      "type": "integer"
     },
     "truncated": {
      "type": "boolean"
     }
    }
   },
   "AuditLog": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "level": {
      "type": "string",
      "description": "info | warning | error | critical"
     },
     "action": {
      "type": "string"
     },
     "details": {
      "type": [
       "string",
       "null"
      ]
     },
     "payload": {
      "type": [
       "object",
       "null"
      ],
      "description": "Structured context; origin service in payload.source."
     },
     "userEmail": {
      "type": [
       "string",
       "null"
      ],
      "description": "Actor."
     },
     "chainHash": {
      "type": [
       "string",
       "null"
      ],
      "description": "SHA-256 of this row chained to the previous one."
     },
     "prevHash": {
      "type": [
       "string",
       "null"
      ]
     },
     "chainSeq": {
      "type": [
       "integer",
       "null"
      ]
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "AuditLogPage": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "total": {
      "type": "integer"
     },
     "logs": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/AuditLog"
      }
     },
     "counts": {
      "type": "object"
     },
     "windowDays": {
      "type": "integer"
     },
     "retentionDays": {
      "type": [
       "integer",
       "null"
      ]
     },
     "limit": {
      "type": "integer"
     },
     "page": {
      "type": "integer"
     }
    }
   },
   "AuditLogExport": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "exportedAt": {
      "type": "string",
      "format": "date-time"
     },
     "count": {
      "type": "integer"
     },
     "truncated": {
      "type": "boolean"
     },
     "windowDays": {
      "type": "integer"
     },
     "logs": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/AuditLog"
      }
     }
    }
   },
   "AuditChainVerification": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "ok": {
      "type": "boolean"
     },
     "chained": {
      "type": "integer"
     },
     "unchained": {
      "type": "integer"
     },
     "firstBrokenId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "brokenType": {
      "type": [
       "string",
       "null"
      ]
     },
     "anchor": {
      "type": "object"
     }
    }
   },
   "ApiToken": {
    "description": "An API token's metadata. The token value itself is only ever returned once, at creation; its hash is never returned.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "userId": {
      "type": "integer",
      "description": "Owner."
     },
     "owner": {
      "type": [
       "string",
       "null"
      ]
     },
     "name": {
      "type": "string"
     },
     "tokenPrefix": {
      "type": "string",
      "description": "Non-secret leading characters (e.g. zx_AbC123), to recognise a token."
     },
     "scopes": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Permissions granted at creation. At use they are intersected with the owner's current permissions."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "expiresAt": {
      "type": "string",
      "format": "date-time"
     },
     "lastUsedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "lastUsedIp": {
      "type": [
       "string",
       "null"
      ]
     },
     "revokedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "revokedBy": {
      "type": [
       "string",
       "null"
      ]
     },
     "revokeReason": {
      "type": [
       "string",
       "null"
      ]
     },
     "status": {
      "type": "string",
      "enum": [
       "active",
       "expired",
       "revoked"
      ]
     }
    }
   },
   "ApiTokenScopeCatalog": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "scopes": {
      "type": "array",
      "items": {
       "type": "object"
      }
     },
     "defaultExpiryDays": {
      "type": "integer"
     },
     "maxExpiryDays": {
      "type": "integer"
     }
    }
   },
   "ApiTokenCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string"
     },
     "expiresInDays": {
      "type": "integer"
     },
     "scopes": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Subset of your permissions."
     }
    },
    "required": [
     "name",
     "scopes"
    ]
   },
   "ApiTokenCreated": {
    "x-kind": "in a reply",
    "allOf": [
     {
      "$ref": "#/components/schemas/ApiToken"
     },
     {
      "type": "object",
      "properties": {
       "token": {
        "type": "string",
        "description": "The plaintext zx_… token. Shown once."
       }
      }
     }
    ]
   },
   "ReportSchedule": {
    "description": "Answered by the schedule list, create and update routes. The webhook address is never returned in full.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "name": {
      "type": "string"
     },
     "kind": {
      "type": "string",
      "description": "compliance | overview | query | lifecycle | device-posture | budget | crosswalk | type2"
     },
     "framework": {
      "type": [
       "string",
       "null"
      ]
     },
     "queries": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Data set names. Empty array when none."
     },
     "filters": {
      "type": "object",
      "description": "Filter values keyed by filter name. Empty object when none."
     },
     "window": {
      "type": [
       "object",
       "null"
      ],
      "description": "{ from, to }"
     },
     "sections": {
      "type": [
       "array",
       "null"
      ],
      "items": {
       "type": "string"
      },
      "description": "summary | controls | tables"
     },
     "columns": {
      "type": [
       "object",
       "null"
      ],
      "description": "Query reports only."
     },
     "groupBy": {
      "type": [
       "string",
       "null"
      ]
     },
     "variant": {
      "type": [
       "string",
       "null"
      ],
      "description": "Budget reports only."
     },
     "lifecycleType": {
      "type": [
       "string",
       "null"
      ],
      "description": "leaver | joiner"
     },
     "formats": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "pdf | csv | xlsx | pptx"
     },
     "recipients": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Email addresses."
     },
     "everyHours": {
      "type": "integer",
      "description": "1 to 2160"
     },
     "enabled": {
      "type": "boolean"
     },
     "webhookUrl": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true,
      "description": "Masked: the host and the last four characters."
     },
     "webhookConfigured": {
      "type": "boolean",
      "readOnly": true
     },
     "webhookSigned": {
      "type": "boolean",
      "readOnly": true,
      "description": "True when a signing secret is stored."
     },
     "webhookSecretRotatedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "webhookSigningSecret": {
      "type": "string",
      "readOnly": true,
      "description": "Present only on the reply that issued it."
     },
     "lastRunAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "lastStatus": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true,
      "description": "The status of the last run."
     },
     "failureAlertedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "owner": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true,
      "description": "updatedBy, or createdBy when it was never edited."
     },
     "createdBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true,
      "description": "Email address."
     },
     "updatedBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true,
      "description": "Email address."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "ReportRun": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "scheduleId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "null for a run that no schedule started."
     },
     "scheduleName": {
      "type": [
       "string",
       "null"
      ]
     },
     "kind": {
      "type": "string"
     },
     "framework": {
      "type": [
       "string",
       "null"
      ]
     },
     "trigger": {
      "type": "string",
      "description": "scheduled | manual"
     },
     "triggeredBy": {
      "type": [
       "string",
       "null"
      ]
     },
     "status": {
      "type": "string",
      "description": "running | ok | failed | interrupted"
     },
     "error": {
      "type": [
       "string",
       "null"
      ],
      "description": "Why the run failed."
     },
     "readinessScore": {
      "type": [
       "integer",
       "null"
      ]
     },
     "passCount": {
      "type": [
       "integer",
       "null"
      ]
     },
     "failCount": {
      "type": [
       "integer",
       "null"
      ]
     },
     "manualCount": {
      "type": [
       "integer",
       "null"
      ]
     },
     "totalControls": {
      "type": [
       "integer",
       "null"
      ]
     },
     "naCount": {
      "type": [
       "integer",
       "null"
      ]
     },
     "applicable": {
      "type": [
       "integer",
       "null"
      ]
     },
     "delivered": {
      "type": "boolean",
      "description": "Whether the email was sent."
     },
     "deliveryError": {
      "type": [
       "string",
       "null"
      ]
     },
     "deliveryAttempts": {
      "type": "integer"
     },
     "deliveryMode": {
      "type": [
       "string",
       "null"
      ]
     },
     "nextRetryAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "claimedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "durationMs": {
      "type": [
       "integer",
       "null"
      ]
     },
     "observationFrom": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "observationTo": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "artifactHash": {
      "type": [
       "string",
       "null"
      ],
      "description": "SHA-256 of the stored PDF."
     },
     "csvHash": {
      "type": [
       "string",
       "null"
      ]
     },
     "xlsxHash": {
      "type": [
       "string",
       "null"
      ]
     },
     "pdfSignature": {
      "type": [
       "object",
       "null"
      ],
      "description": "Carries status: signed | unsigned."
     },
     "hasPdf": {
      "type": "boolean",
      "readOnly": true,
      "description": "A PDF is stored for this run."
     },
     "hasCsv": {
      "type": "boolean",
      "readOnly": true
     },
     "hasXlsx": {
      "type": "boolean",
      "readOnly": true
     },
     "sealed": {
      "type": "boolean",
      "readOnly": true,
      "description": "The seal value itself is never returned."
     },
     "sealMissing": {
      "type": "boolean",
      "readOnly": true,
      "description": "A stored file that should carry a seal has none."
     },
     "canonical": {
      "type": [
       "boolean",
       "null"
      ]
     },
     "redacted": {
      "type": [
       "object",
       "null"
      ]
     },
     "rowCounts": {
      "type": "object",
      "description": "Empty object when the run stored no row counts."
     },
     "retainUntil": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "legalHold": {
      "type": "boolean"
     },
     "verifyUrl": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true,
      "description": "null when the run stored no file hash."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "SavedReport": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "name": {
      "type": "string",
      "description": "Up to 120 characters."
     },
     "kind": {
      "type": "string",
      "description": "compliance | overview | query | lifecycle | device-posture | budget | crosswalk | type2"
     },
     "framework": {
      "type": [
       "string",
       "null"
      ]
     },
     "queries": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Data set names. Empty array when none."
     },
     "filters": {
      "type": "object",
      "description": "Filter values keyed by filter name. Empty object when none."
     },
     "window": {
      "type": [
       "object",
       "null"
      ],
      "description": "{ from, to }"
     },
     "sections": {
      "type": [
       "array",
       "null"
      ],
      "items": {
       "type": "string"
      },
      "description": "summary | controls | tables"
     },
     "columns": {
      "type": [
       "object",
       "null"
      ],
      "description": "Query reports only."
     },
     "groupBy": {
      "type": [
       "string",
       "null"
      ]
     },
     "variant": {
      "type": [
       "string",
       "null"
      ],
      "description": "Budget reports only."
     },
     "lifecycleType": {
      "type": [
       "string",
       "null"
      ],
      "description": "leaver | joiner"
     },
     "format": {
      "type": "string",
      "description": "table | pdf"
     },
     "createdBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true,
      "description": "Email address."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "ReportShareLink": {
    "description": "One link to one sealed file. The create reply adds token (string), once; lists carry tokenHint only.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "runId": {
      "type": "integer",
      "readOnly": true
     },
     "format": {
      "type": "string",
      "description": "pdf | csv"
     },
     "expiresAt": {
      "type": "string",
      "format": "date-time"
     },
     "revoked": {
      "type": "boolean"
     },
     "viewCount": {
      "type": "integer",
      "readOnly": true
     },
     "lastViewedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "createdBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "tokenHint": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true,
      "description": "The last few characters of the token."
     },
     "recipientEmail": {
      "type": [
       "string",
       "null"
      ]
     },
     "recipientLabel": {
      "type": [
       "string",
       "null"
      ],
      "description": "Up to 80 characters."
     }
    }
   },
   "AuditorShare": {
    "description": "An auditor workspace link. The create reply adds token (string), once.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "framework": {
      "type": "string"
     },
     "label": {
      "type": [
       "string",
       "null"
      ],
      "description": "Up to 80 characters."
     },
     "expiresAt": {
      "type": "string",
      "format": "date-time"
     },
     "revoked": {
      "type": "boolean"
     },
     "viewCount": {
      "type": "integer",
      "readOnly": true
     },
     "lastViewedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "createdBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "tokenHint": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     }
    }
   },
   "AuditorRequest": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "readOnly": true,
      "description": "16 hex characters."
     },
     "shareId": {
      "type": "integer",
      "readOnly": true,
      "description": "The auditor link it was raised on."
     },
     "framework": {
      "type": "string",
      "readOnly": true
     },
     "controlId": {
      "type": "string",
      "readOnly": true
     },
     "note": {
      "type": "string",
      "readOnly": true,
      "description": "Up to 500 characters."
     },
     "status": {
      "type": "string",
      "readOnly": true,
      "description": "open | resolved"
     },
     "response": {
      "type": [
       "string",
       "null"
      ],
      "description": "Up to 500 characters."
     },
     "respondedBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "respondedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "TenantExportJob": {
    "description": "One company data export. Answered by the Company Data Export routes. The encryption parameters and the download link are never part of it.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "status": {
      "type": "string",
      "readOnly": true,
      "description": "queued | running | ready | failed | expired"
     },
     "progress": {
      "type": "integer",
      "readOnly": true,
      "description": "0 to 100."
     },
     "stage": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true,
      "description": "The current step, in words."
     },
     "requestedBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true,
      "description": "The email or username of the administrator who started it."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "startedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "completedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "expiresAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true,
      "description": "When the file is deleted: 24 hours after it became ready."
     },
     "expiredAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true,
      "description": "When the file was deleted."
     },
     "sizeBytes": {
      "type": [
       "integer",
       "null"
      ],
      "readOnly": true,
      "description": "The size of the ZIP."
     },
     "zipSha256": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true,
      "description": "The SHA-256 the downloaded ZIP must have."
     },
     "summary": {
      "type": [
       "object",
       "null"
      ],
      "readOnly": true,
      "description": "{ totals, datasets: [{ name, rows, incomplete }], notes[] } once ready."
     },
     "error": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true,
      "description": "Why a failed job failed."
     },
     "downloadCount": {
      "type": "integer",
      "readOnly": true
     },
     "lastDownloadedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true,
      "description": "When the current link was used."
     }
    }
   },
   "EnvironmentProfile": {
    "description": "The systems the organisation has declared it uses. Answered by the environment profile routes.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "selected": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Identifiers of the declared systems. Empty array when none."
     },
     "custom": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { id: string, name: string, category: string }. Empty array when none."
     },
     "updatedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "updatedBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true,
      "description": "The email of whoever saved it last."
     }
    }
   },
   "TypeTwoReport": {
    "description": "How each control of one framework held over a period. Answered by the type-two route.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "framework": {
      "type": "string"
     },
     "windowDays": {
      "type": "integer"
     },
     "from": {
      "type": "string",
      "format": "date"
     },
     "to": {
      "type": "string",
      "format": "date"
     },
     "snapshotCount": {
      "type": "integer",
      "description": "Automatic snapshots found in the period."
     },
     "firstAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "description": "The first snapshot in the period."
     },
     "lastAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "description": "The last snapshot in the period."
     },
     "heldThroughout": {
      "type": "integer",
      "description": "Count of rows with that verdict."
     },
     "hadGaps": {
      "type": "integer",
      "description": "Count of rows with the verdict Gaps in period."
     },
     "noData": {
      "type": "integer",
      "description": "Count of rows with the verdict No period data."
     },
     "incompleteCoverage": {
      "type": "integer",
      "description": "Count of rows with that verdict."
     },
     "rows": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "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."
     }
    }
   },
   "ReportRunHold": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "legalHold": {
      "type": "boolean",
      "description": "The state you want. Anything that is not true or false answers 400. Left out, the hold is flipped."
     }
    }
   },
   "ReportShareCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "format": {
      "type": "string",
      "enum": [
       "pdf",
       "csv"
      ],
      "description": "Default pdf."
     },
     "confirmCsv": {
      "type": "boolean",
      "description": "Must be true to share the CSV; otherwise 400 with code CSV_CONFIRM_REQUIRED."
     },
     "ttlHours": {
      "type": "integer",
      "description": "Default 168. 1 to 2160."
     },
     "recipientEmail": {
      "type": "string"
     },
     "recipientLabel": {
      "type": "string",
      "description": "Up to 80 characters."
     }
    }
   },
   "AuditorEvidenceRequestCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "controlId": {
      "type": "string",
      "description": "A control of the framework the link was made for."
     },
     "note": {
      "type": "string",
      "description": "Up to 500 characters."
     }
    },
    "required": [
     "controlId",
     "note"
    ]
   },
   "EvidenceItemCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "controlId": {
      "type": "string"
     },
     "url": {
      "type": "string",
      "description": "A link must be http or https."
     },
     "kind": {
      "type": "string",
      "description": "file for a file already uploaded to this server. Anything else is a link."
     },
     "label": {
      "type": "string"
     },
     "framework": {
      "type": "string"
     }
    },
    "required": [
     "controlId",
     "url"
    ]
   },
   "SecurityExceptionRequest": {
    "description": "Asks to accept the risk of one finding until a date.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "justification": {
      "type": "string",
      "description": "Empty, or only spaces, answers 400 with code JUSTIFICATION_REQUIRED."
     },
     "acceptedUntil": {
      "type": "string",
      "format": "date-time",
      "description": "When the acceptance ends. Missing, not a date, or not in the future answers 400 with code EXPIRY_REQUIRED."
     }
    },
    "required": [
     "justification",
     "acceptedUntil"
    ]
   },
   "SecurityExceptionRevoke": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "note": {
      "type": "string"
     }
    }
   },
   "FindingsArchive": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "scope": {
      "type": "string",
      "enum": [
       "all",
       "remote",
       "local"
      ],
      "description": "Default all. Also read from ?scope when the body does not carry it. Any other value answers 400."
     }
    }
   },
   "AssetCriticalityUpdate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "criticality": {
      "type": "string",
      "description": "low | medium | high | crit. critical is accepted and stored as crit."
     }
    },
    "required": [
     "criticality"
    ]
   },
   "RemediationHandoff": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "handoffRef": {
      "type": "string",
      "description": "A reference to where the fix is tracked. Cut to 255 characters."
     }
    }
   },
   "TicketingUpdate": {
    "description": "Both values are stored on every call: a key left out is stored as empty.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "providerId": {
      "type": "string",
      "description": "The connected ticketing system that receives the tickets. Empty switches automatic ticketing off."
     },
     "projectKey": {
      "type": "string"
     }
    }
   },
   "Policy": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "readOnly": true,
      "description": "16 hex characters."
     },
     "name": {
      "type": "string",
      "description": "Up to 140 characters."
     },
     "version": {
      "type": "string",
      "description": "Up to 20 characters; '1.0' when not sent."
     },
     "owner": {
      "type": "string",
      "description": "Empty string when not set."
     },
     "link": {
      "type": "string",
      "description": "Empty string when not set."
     },
     "description": {
      "type": "string"
     },
     "frameworks": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Up to 8."
     },
     "status": {
      "type": "string",
      "readOnly": true,
      "description": "draft | approved"
     },
     "requireAck": {
      "type": "boolean"
     },
     "audience": {
      "type": [
       "object",
       "null"
      ],
      "description": "Present only once it was set. { kind: 'everyone' } or { kind: 'rule', departments: string[], positions: string[], locations: string[] }."
     },
     "ackEveryMonths": {
      "type": "integer",
      "description": "Present only once it was set. 0 to 36."
     },
     "ackDueDays": {
      "type": "integer",
      "description": "Present only once it was set. 1 to 180."
     },
     "signSections": {
      "type": "boolean",
      "description": "Present only once it was set."
     },
     "controlIds": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Present only once it was set."
     },
     "nextReviewDue": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "reviewedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date",
      "readOnly": true,
      "description": "Set by approval, never by a write."
     },
     "reviewedBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "draftedBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "draftedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "templateId": {
      "type": [
       "string",
       "null"
      ]
     },
     "templateVersion": {
      "type": [
       "string",
       "null"
      ]
     },
     "hasDocument": {
      "type": "boolean",
      "readOnly": true,
      "description": "Whether the policy has text."
     },
     "mappedControls": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "readOnly": true,
      "description": "The controls the policy counts towards."
     },
     "ackSettings": {
      "type": "object",
      "readOnly": true,
      "description": "{ everyMonths: integer | null, dueDays: integer, signSections: boolean, audience: object | null }."
     },
     "createdBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "Risk": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "readOnly": true,
      "description": "16 hex characters."
     },
     "title": {
      "type": "string",
      "description": "Up to 140 characters."
     },
     "description": {
      "type": "string",
      "description": "Up to 1,000 characters."
     },
     "category": {
      "type": "string",
      "description": "'General' when not sent."
     },
     "likelihood": {
      "type": "integer",
      "description": "1 to 5"
     },
     "impact": {
      "type": "integer",
      "description": "1 to 5"
     },
     "score": {
      "type": "integer",
      "readOnly": true,
      "description": "likelihood x impact"
     },
     "status": {
      "type": "string",
      "description": "open | treating | accepted | closed"
     },
     "treatment": {
      "type": "string"
     },
     "owner": {
      "type": "string"
     },
     "controlIds": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "At most 40."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "createdBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     }
    }
   },
   "RegistersVendor": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "readOnly": true,
      "description": "16 hex characters."
     },
     "name": {
      "type": "string",
      "description": "Up to 140 characters."
     },
     "category": {
      "type": "string",
      "description": "'SaaS' when not sent."
     },
     "tier": {
      "type": "string",
      "description": "low | medium | high | critical"
     },
     "owner": {
      "type": "string"
     },
     "notes": {
      "type": "string",
      "description": "Up to 500 characters."
     },
     "soc2OnFile": {
      "type": "boolean"
     },
     "dataShared": {
      "type": "boolean"
     },
     "reviewStatus": {
      "type": "string",
      "description": "none | requested | received | reviewed"
     },
     "reviewedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "reviewer": {
      "type": "string"
     },
     "riskDecision": {
      "type": "string",
      "description": "approved | conditional | rejected, or an empty string before a decision."
     },
     "nextReviewDue": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "documents": {
      "type": "object",
      "description": "{ soc2: boolean, iso: boolean, pentest: boolean, dpa: boolean, questionnaire: boolean }"
     },
     "reviews": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/VendorReview"
      },
      "readOnly": true,
      "description": "At most 20, oldest first."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "createdBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     }
    }
   },
   "VendorReview": {
    "description": "One entry of a vendor’s review history.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "readOnly": true,
      "description": "12 hex characters."
     },
     "kind": {
      "type": "string",
      "readOnly": true,
      "description": "requested | completed"
     },
     "at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "by": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "reviewer": {
      "type": "string",
      "readOnly": true
     },
     "dueDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date",
      "readOnly": true,
      "description": "On a requested entry."
     },
     "decision": {
      "type": "string",
      "readOnly": true,
      "description": "On a completed entry: approved | conditional | rejected."
     },
     "riskRating": {
      "type": "string",
      "readOnly": true,
      "description": "On a completed entry: low | medium | high | critical."
     },
     "summary": {
      "type": "string",
      "readOnly": true,
      "description": "On a completed entry. Up to 1,000 characters."
     },
     "documents": {
      "type": "object",
      "readOnly": true,
      "description": "On a completed entry: the same five flags as Vendor.documents."
     },
     "nextReviewDue": {
      "type": [
       "string",
       "null"
      ],
      "format": "date",
      "readOnly": true,
      "description": "On a completed entry."
     }
    }
   },
   "EvidenceItem": {
    "description": "Evidence a person attached to a control.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "controlId": {
      "type": "string",
      "readOnly": true
     },
     "kind": {
      "type": "string",
      "readOnly": true,
      "description": "file | link"
     },
     "url": {
      "type": "string",
      "readOnly": true
     },
     "label": {
      "type": "string",
      "readOnly": true,
      "description": "Empty string when none was given."
     },
     "sha256": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true,
      "description": "For a file: computed by the server from the stored bytes."
     },
     "addedBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "addedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "TrustCenterSettings": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "published": {
      "type": "boolean"
     },
     "headline": {
      "type": "string",
      "description": "Up to 140 characters."
     },
     "intro": {
      "type": "string",
      "description": "Up to 1,000 characters."
     },
     "frameworks": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Up to 8 catalogue names."
     },
     "showReadiness": {
      "type": "boolean"
     },
     "practices": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { title: string, detail: string }. Up to 20."
     },
     "autoPractices": {
      "type": "boolean"
     },
     "contactEmail": {
      "type": "string"
     },
     "updatedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "updatedBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     }
    }
   },
   "BackupPoint": {
    "description": "One row of GET /compliance/reports/backups.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "filename": {
      "type": "string",
      "readOnly": true
     },
     "type": {
      "type": "string",
      "readOnly": true,
      "description": "dump | base"
     },
     "restorable": {
      "type": "boolean",
      "readOnly": true
     },
     "fetch": {
      "type": "boolean",
      "readOnly": true,
      "description": "Present only on a backup with no local file: true when it is copied back from your storage before it is restored."
     },
     "foundInStorage": {
      "type": "boolean",
      "readOnly": true,
      "description": "Present (true) only on a backup found in your storage that this server holds no record of."
     },
     "sizeBytes": {
      "type": "integer",
      "readOnly": true
     },
     "modifiedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "pgVersion": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true,
      "description": "The PostgreSQL version the dump was taken from; null when it could not be read."
     },
     "serverPgMajor": {
      "type": [
       "integer",
       "null"
      ],
      "readOnly": true
     },
     "versionCompatible": {
      "type": "boolean",
      "readOnly": true,
      "description": "true when either version is not known."
     },
     "local": {
      "type": "boolean",
      "readOnly": true,
      "description": "A copy is on this server."
     },
     "offbox": {
      "type": "boolean",
      "readOnly": true,
      "description": "A copy is in your storage."
     },
     "offboxMissing": {
      "type": "boolean",
      "readOnly": true
     },
     "previousDestination": {
      "type": "boolean",
      "readOnly": true,
      "description": "Not sent on a foundInStorage row."
     },
     "destinationKey": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "offboxInfo": {
      "type": [
       "object",
       "null"
      ],
      "readOnly": true,
      "description": "{ provider: string, bucket: string | null, region: string | null, key: string | null }"
     }
    }
   },
   "BackupAuditEntry": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "action": {
      "type": "string",
      "readOnly": true
     },
     "details": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "userEmail": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "level": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "WalArchiveSummary": {
    "description": "With ?list=1 the reply is { count: integer, segments: WalSegment[] } instead.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "count": {
      "type": "integer",
      "readOnly": true
     },
     "totalBytes": {
      "type": "integer",
      "readOnly": true
     },
     "newest": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "oldest": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "provider": {
      "type": "string",
      "readOnly": true
     },
     "bucket": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "region": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     }
    }
   },
   "WalSegment": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "name": {
      "type": "string",
      "readOnly": true
     },
     "sizeBytes": {
      "type": "integer",
      "readOnly": true
     },
     "uploadedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "key": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "bucket": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "region": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "provider": {
      "type": "string",
      "readOnly": true
     }
    }
   },
   "ScanSchedule": {
    "description": "GET and POST /auth/security-lab/scan-schedule. Every field is optional in the POST body; send at least one.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "enabled": {
      "type": "boolean"
     },
     "intervalHours": {
      "type": "integer",
      "description": "1 to 168; 24 when never set."
     },
     "lastRunAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "nextDueAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true,
      "description": "null when the schedule is off, has never run, or is already due."
     },
     "reportDeliver": {
      "type": "boolean"
     },
     "reportRecipients": {
      "type": "string",
      "description": "Empty string when none."
     },
     "externalEnabled": {
      "type": "boolean"
     },
     "externalIntervalHours": {
      "type": "integer"
     },
     "externalLastRunAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "trivyImages": {
      "type": "string",
      "description": "Empty string when none."
     },
     "slaAlertEnabled": {
      "type": "boolean",
      "description": "true when never set."
     },
     "slaAlertPermission": {
      "type": "string",
      "description": "The permission whose holders are emailed."
     }
    }
   },
   "SecurityIssue": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "findingId": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "fingerprint": {
      "type": "string",
      "readOnly": true
     },
     "probe": {
      "type": "string",
      "readOnly": true
     },
     "target": {
      "type": "string",
      "readOnly": true
     },
     "title": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "severity": {
      "type": "string",
      "readOnly": true,
      "description": "Critical | High | Medium | Low | Inconclusive | Informational; treat any other value as the lowest rank."
     },
     "cvssVector": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "cvssScore": {
      "type": [
       "number",
       "null"
      ],
      "readOnly": true
     },
     "cveId": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "pkgName": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "installedVersion": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "fixedVersion": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "location": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "status": {
      "type": "string",
      "description": "open | acknowledged | in_remediation | retest_pending | risk_accepted | false_positive | verified_fixed"
     },
     "statusNote": {
      "type": [
       "string",
       "null"
      ]
     },
     "owner": {
      "type": [
       "string",
       "null"
      ]
     },
     "ticketRef": {
      "type": [
       "string",
       "null"
      ]
     },
     "ticketProvider": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "ticketStatus": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "slaDueAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "slaBreachAlertedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "kevListed": {
      "type": "boolean",
      "readOnly": true
     },
     "epssScore": {
      "type": [
       "number",
       "null"
      ],
      "readOnly": true,
      "description": "0 to 1"
     },
     "confirmedExposure": {
      "type": "boolean",
      "readOnly": true,
      "description": "true only when the issue is KEV-listed and the installed version is known to be older than the fixed one."
     },
     "regressed": {
      "type": "boolean",
      "readOnly": true
     },
     "firstSeenAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "lastSeenAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "evidence": {
      "type": [
       "object",
       "null"
      ],
      "readOnly": true
     },
     "archivedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "SecurityRemediation": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "findingId": {
      "type": [
       "string",
       "null"
      ],
      "format": "uuid",
      "readOnly": true
     },
     "issueId": {
      "type": [
       "string",
       "null"
      ],
      "format": "uuid",
      "readOnly": true
     },
     "findingProbe": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "findingTarget": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "targetKind": {
      "type": "string",
      "readOnly": true
     },
     "targetId": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "targetLabel": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "recommendedPayload": {
      "type": "object",
      "readOnly": true
     },
     "status": {
      "type": "string",
      "readOnly": true,
      "description": "Values the service writes include proposed, handed_off, resolved and failed."
     },
     "handoffRef": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "createdBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "timeline": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "readOnly": true
     },
     "evidence": {
      "type": [
       "object",
       "null"
      ],
      "readOnly": true
     },
     "resolvedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "EmployeeListItem": {
    "description": "One row of GET /user-management/employees.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "firstName": {
      "type": [
       "string",
       "null"
      ]
     },
     "lastName": {
      "type": [
       "string",
       "null"
      ]
     },
     "email": {
      "type": "string"
     },
     "name": {
      "type": "string",
      "description": "First and last name joined; the email address when both are empty."
     },
     "position": {
      "type": [
       "string",
       "null"
      ],
      "description": "Job title."
     },
     "department": {
      "type": [
       "string",
       "null"
      ]
     },
     "managerId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "managerName": {
      "type": [
       "string",
       "null"
      ]
     },
     "status": {
      "type": [
       "string",
       "null"
      ],
      "description": "The account status, for example active."
     },
     "employeeId": {
      "type": [
       "string",
       "null"
      ],
      "description": "Your own employee number, not the Zonitix id."
     },
     "photoUrl": {
      "type": [
       "string",
       "null"
      ]
     },
     "terminationDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "hireDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "employmentType": {
      "type": [
       "string",
       "null"
      ]
     },
     "workLocation": {
      "type": [
       "string",
       "null"
      ]
     },
     "level": {
      "type": [
       "string",
       "null"
      ]
     },
     "reminders": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/EmployeeReminder"
      },
      "description": "Empty for a person who has left."
     }
    }
   },
   "EmployeeReminder": {
    "description": "An upcoming date on a file. Worked out on each read, not stored.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "kind": {
      "type": "string",
      "enum": [
       "probation",
       "contract",
       "right_to_work"
      ]
     },
     "label": {
      "type": "string",
      "description": "\"Probation ends\", \"Contract ends\" or \"Right to work expires\"."
     },
     "date": {
      "type": "string",
      "format": "date"
     },
     "days": {
      "type": "integer",
      "description": "Days from today; negative once the date has passed."
     },
     "overdue": {
      "type": "boolean",
      "description": "true when days is below 0."
     }
    }
   },
   "EmployeeFile": {
    "description": "GET /user-management/employees/:userId, and the reply of every write on a file.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "user": {
      "$ref": "#/components/schemas/EmployeeFileUser"
     },
     "profile": {
      "$ref": "#/components/schemas/EmployeeProfile"
     },
     "emergencyContacts": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/EmergencyContact"
      },
      "description": "In the stored order."
     },
     "history": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/EmploymentEvent"
      },
      "description": "The newest 200 job changes, newest effective date first."
     },
     "reminders": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/EmployeeReminder"
      }
     },
     "authority": {
      "allOf": [
       {
        "$ref": "#/components/schemas/HrAuthority"
       }
      ],
      "description": "Which system owns each governed field."
     },
     "people": {
      "type": "object",
      "description": "{ \"<user id>\": name } for the managers named in history."
     }
    }
   },
   "EmployeeFileUser": {
    "description": "The identity half of an employee file.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "firstName": {
      "type": [
       "string",
       "null"
      ]
     },
     "lastName": {
      "type": [
       "string",
       "null"
      ]
     },
     "email": {
      "type": "string"
     },
     "name": {
      "type": "string"
     },
     "position": {
      "type": [
       "string",
       "null"
      ]
     },
     "department": {
      "type": [
       "string",
       "null"
      ]
     },
     "managerId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "managerName": {
      "type": [
       "string",
       "null"
      ]
     },
     "status": {
      "type": [
       "string",
       "null"
      ]
     },
     "employeeId": {
      "type": [
       "string",
       "null"
      ]
     },
     "phoneNumber": {
      "type": [
       "string",
       "null"
      ]
     },
     "personalEmail": {
      "type": [
       "string",
       "null"
      ]
     },
     "photoUrl": {
      "type": [
       "string",
       "null"
      ]
     },
     "terminationDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "personaId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "source": {
      "type": [
       "string",
       "null"
      ],
      "description": "Where the account came from, for example local."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time"
     }
    }
   },
   "EmployeeProfile": {
    "description": "The HR half of an employee file. Every field is null until it is set.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "preferredName": {
      "type": [
       "string",
       "null"
      ]
     },
     "legalName": {
      "type": [
       "string",
       "null"
      ]
     },
     "nationality": {
      "type": [
       "string",
       "null"
      ]
     },
     "hireDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "originalHireDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "employmentType": {
      "type": [
       "string",
       "null"
      ]
     },
     "workLocation": {
      "type": [
       "string",
       "null"
      ]
     },
     "level": {
      "type": [
       "string",
       "null"
      ]
     },
     "costCenter": {
      "type": [
       "string",
       "null"
      ]
     },
     "legalEntity": {
      "type": [
       "string",
       "null"
      ]
     },
     "probationEndDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "contractEndDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "noticePeriod": {
      "type": [
       "string",
       "null"
      ],
      "description": "Free text, up to 255 characters."
     },
     "rightToWorkStatus": {
      "type": [
       "string",
       "null"
      ],
      "enum": [
       "verified",
       "pending",
       "expired",
       "not_required",
       null
      ]
     },
     "rightToWorkVerifiedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "rightToWorkExpiresAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "hasDateOfBirth": {
      "type": "boolean",
      "description": "The date itself is never in a file; reveal it with POST /reveal."
     },
     "hasHomeAddress": {
      "type": "boolean",
      "description": "The address itself is never in a file; reveal it with POST /reveal."
     }
    }
   },
   "EmergencyContact": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "name": {
      "type": [
       "string",
       "null"
      ],
      "description": "null only when the stored value cannot be read."
     },
     "relationship": {
      "type": [
       "string",
       "null"
      ]
     },
     "phone": {
      "type": [
       "string",
       "null"
      ]
     }
    }
   },
   "EmploymentEvent": {
    "description": "One job change in a file's history.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "userId": {
      "type": "integer"
     },
     "type": {
      "type": "string",
      "description": "promotion | transfer | manager_change | title_change | other for a change a person recorded."
     },
     "effectiveDate": {
      "type": "string",
      "format": "date"
     },
     "status": {
      "type": "string",
      "enum": [
       "applied",
       "scheduled",
       "cancelled"
      ],
      "description": "scheduled until its effective date arrives."
     },
     "changes": {
      "type": "object",
      "description": "{ \"<field>\": { from, to } } for each field that changed."
     },
     "reason": {
      "type": [
       "string",
       "null"
      ]
     },
     "source": {
      "type": "string",
      "description": "manual for a change a person recorded."
     },
     "sourceRef": {
      "type": [
       "string",
       "null"
      ]
     },
     "actorEmail": {
      "type": [
       "string",
       "null"
      ]
     },
     "appliedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "createdAt": {
      "type": "string",
      "format": "date-time"
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time"
     }
    }
   },
   "HrAuthority": {
    "description": "Which system owns people data. Also the base of GET /employees/hr-settings.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "mode": {
      "type": "string",
      "enum": [
       "zonitix",
       "external"
      ]
     },
     "providerId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The connected HR system, when mode is external."
     },
     "providerName": {
      "type": [
       "string",
       "null"
      ]
     },
     "providerMissing": {
      "type": "boolean",
      "description": "true when mode is external and that connection no longer exists."
     },
     "fields": {
      "type": "object",
      "description": "{ \"<field key>\": \"zonitix\" | \"external\" }: the owner of each governed field."
     },
     "overrides": {
      "type": "object",
      "description": "The per-field choices as saved."
     },
     "lastSync": {
      "type": [
       "object",
       "null"
      ],
      "description": "What the last sync from the HR system recorded."
     }
    }
   },
   "CompensationRecord": {
    "description": "The amount is never part of this object: reveal one with POST /compensation/:recordId/reveal.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "effectiveDate": {
      "type": "string",
      "format": "date"
     },
     "currency": {
      "type": "string",
      "description": "A 3-letter code."
     },
     "payPeriod": {
      "type": "string",
      "enum": [
       "annual",
       "monthly",
       "hourly"
      ]
     },
     "bonusPct": {
      "type": [
       "number",
       "null"
      ]
     },
     "reason": {
      "type": [
       "string",
       "null"
      ]
     },
     "source": {
      "type": "string",
      "description": "manual for a record a person typed in."
     },
     "sourceOfferId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The signed offer the record was carried over from."
     },
     "actorEmail": {
      "type": [
       "string",
       "null"
      ]
     },
     "createdAt": {
      "type": "string",
      "format": "date-time"
     },
     "voidedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "voidedBy": {
      "type": [
       "string",
       "null"
      ]
     },
     "voidReason": {
      "type": [
       "string",
       "null"
      ]
     }
    }
   },
   "EmployeeDocument": {
    "description": "The entry for a stored file. The file itself is fetched from the download route.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "name": {
      "type": "string",
      "description": "Up to 180 characters."
     },
     "category": {
      "type": "string",
      "enum": [
       "contract",
       "offer",
       "job_change",
       "policy",
       "certificate",
       "review",
       "letter",
       "other"
      ]
     },
     "visibility": {
      "type": "string",
      "enum": [
       "hr",
       "employee",
       "manager"
      ]
     },
     "mimeType": {
      "type": [
       "string",
       "null"
      ],
      "description": "null until the file has been uploaded."
     },
     "sizeBytes": {
      "type": "integer",
      "description": "0 until the file has been uploaded."
     },
     "expiresAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "uploadedBy": {
      "type": [
       "string",
       "null"
      ],
      "description": "The email address of the person who created the entry."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time"
     }
    }
   },
   "DepartureRecord": {
    "description": "The leaving record on a file.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "lastDay": {
      "type": "string",
      "format": "date"
     },
     "departureType": {
      "type": [
       "string",
       "null"
      ],
      "enum": [
       "voluntary",
       "involuntary",
       "end_of_contract",
       null
      ]
     },
     "reasonCode": {
      "type": [
       "string",
       "null"
      ],
      "description": "One of the keys in the reply's reasons list."
     },
     "rehireEligible": {
      "type": [
       "boolean",
       "null"
      ]
     },
     "checklist": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { key:string, label:string, done:boolean, doneBy:string|null, doneAt:string (date-time)|null }."
     },
     "source": {
      "type": "string"
     },
     "recordedBy": {
      "type": [
       "string",
       "null"
      ]
     },
     "createdAt": {
      "type": "string",
      "format": "date-time"
     }
    }
   },
   "LeaveBalance": {
    "description": "One per type of time off.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "typeId": {
      "type": "integer"
     },
     "name": {
      "type": "string"
     },
     "paid": {
      "type": "boolean"
     },
     "tracksBalance": {
      "type": "boolean"
     },
     "requiresApproval": {
      "type": "boolean"
     },
     "annualDays": {
      "type": "number"
     },
     "allowNegative": {
      "type": "boolean"
     },
     "isActive": {
      "type": "boolean"
     },
     "allowHours": {
      "type": "boolean"
     },
     "minPartMinutes": {
      "type": "integer",
      "description": "The smallest booking by the hour."
     },
     "maxPartMinutes": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The most per booking by the hour."
     },
     "balance": {
      "type": [
       "number",
       "null"
      ],
      "description": "null for a type that does not track a balance."
     },
     "pending": {
      "type": "number",
      "description": "Days in requests still waiting for a decision."
     },
     "available": {
      "type": [
       "number",
       "null"
      ],
      "description": "balance minus pending, to two places; null without a balance."
     },
     "availableExact": {
      "type": [
       "number",
       "null"
      ],
      "description": "The same figure before rounding."
     },
     "taken": {
      "type": "number",
      "description": "Approved days that start in the current calendar year."
     }
    }
   },
   "LeaveRequest": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "userId": {
      "type": "integer"
     },
     "leaveTypeId": {
      "type": "integer"
     },
     "typeName": {
      "type": [
       "string",
       "null"
      ],
      "description": "Not sent in the reply to a cancel."
     },
     "startDate": {
      "type": "string",
      "format": "date"
     },
     "endDate": {
      "type": "string",
      "format": "date"
     },
     "startHalf": {
      "type": "boolean"
     },
     "endHalf": {
      "type": "boolean"
     },
     "startTime": {
      "x-catalogue-type": "string (HH:MM)",
      "description": "Set only for time off by the hour."
     },
     "minutes": {
      "type": [
       "integer",
       "null"
      ],
      "description": "Set only for time off by the hour."
     },
     "days": {
      "type": "number",
      "description": "Working days the request uses."
     },
     "status": {
      "type": "string",
      "enum": [
       "pending",
       "approved",
       "declined",
       "cancelled"
      ]
     },
     "note": {
      "type": [
       "string",
       "null"
      ],
      "description": "Up to 500 characters."
     },
     "decidedByEmail": {
      "type": [
       "string",
       "null"
      ]
     },
     "decidedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "decisionNote": {
      "type": [
       "string",
       "null"
      ]
     },
     "createdAt": {
      "type": "string",
      "format": "date-time"
     }
    }
   },
   "LeaveType": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "name": {
      "type": "string",
      "description": "Up to 60 characters; unique without regard to case."
     },
     "paid": {
      "type": "boolean"
     },
     "requiresApproval": {
      "type": "boolean"
     },
     "tracksBalance": {
      "type": "boolean"
     },
     "annualDays": {
      "type": "number",
      "description": "0 to 366."
     },
     "accrual": {
      "type": "string",
      "enum": [
       "upfront",
       "monthly"
      ]
     },
     "accrueFrom": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "carryOverMaxDays": {
      "type": "number",
      "description": "0 to 366."
     },
     "allowNegative": {
      "type": "boolean"
     },
     "allowHours": {
      "type": "boolean"
     },
     "minPartMinutes": {
      "type": "integer"
     },
     "maxPartMinutes": {
      "type": [
       "integer",
       "null"
      ]
     },
     "isActive": {
      "type": "boolean",
      "description": "false for a retired type."
     },
     "sortOrder": {
      "type": "integer"
     }
    }
   },
   "WorkingCalendar": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "name": {
      "type": "string"
     },
     "location": {
      "type": [
       "string",
       "null"
      ]
     },
     "workDays": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "The working days of the week."
     },
     "hoursPerDay": {
      "type": "number"
     },
     "dayStart": {
      "x-catalogue-type": "string (HH:MM)"
     },
     "isDefault": {
      "type": "boolean"
     },
     "holidays": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { date:string (date), name:string }, earliest first."
     }
    }
   },
   "Goal": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "userId": {
      "type": "integer"
     },
     "title": {
      "type": "string"
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     },
     "period": {
      "type": [
       "string",
       "null"
      ]
     },
     "dueDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "status": {
      "type": "string",
      "enum": [
       "on_track",
       "at_risk",
       "done",
       "dropped"
      ]
     },
     "progress": {
      "type": "integer"
     },
     "weight": {
      "type": [
       "integer",
       "null"
      ]
     },
     "createdByEmail": {
      "type": [
       "string",
       "null"
      ]
     },
     "completedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "createdAt": {
      "type": "string",
      "format": "date-time"
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time"
     }
    }
   },
   "GoalTemplate": {
    "description": "An example goal.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "title": {
      "type": "string"
     },
     "description": {
      "type": [
       "string",
       "null"
      ],
      "description": "Up to 2,000 characters."
     },
     "isActive": {
      "type": "boolean"
     },
     "sortOrder": {
      "type": "integer"
     }
    }
   },
   "ReviewCycle": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "name": {
      "type": "string"
     },
     "periodLabel": {
      "type": [
       "string",
       "null"
      ]
     },
     "selfDueDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "managerDueDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "questions": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { key:string, label:string, help:string|null }; at most 12."
     },
     "includeRating": {
      "type": "boolean"
     },
     "department": {
      "type": [
       "string",
       "null"
      ],
      "description": "null when the cycle covers everyone."
     },
     "peerFeedback": {
      "type": "string",
      "description": "The colleague-feedback mode of the cycle."
     },
     "calibration": {
      "type": "boolean"
     },
     "ratingsReleased": {
      "type": "boolean"
     },
     "ratingTargets": {
      "type": [
       "object",
       "null"
      ],
      "description": "null when the cycle does not use calibration."
     },
     "goalRatings": {
      "type": "boolean"
     },
     "status": {
      "type": "string",
      "description": "draft, open or closed."
     },
     "launchedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "closedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     }
    }
   },
   "Survey": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "title": {
      "type": "string"
     },
     "intro": {
      "type": [
       "string",
       "null"
      ]
     },
     "questions": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { key:string, label:string, type: scale | enps | text }; at most 20."
     },
     "department": {
      "type": [
       "string",
       "null"
      ],
      "description": "null when everyone is asked."
     },
     "status": {
      "type": "string",
      "description": "draft, open or closed."
     },
     "closesAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "openedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "closedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "expired": {
      "type": "boolean",
      "description": "true once the closing date has passed."
     }
    }
   },
   "TrainingCourse": {
    "description": "A training item. The correct answers of its knowledge check are never part of this object.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "title": {
      "type": "string"
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     },
     "url": {
      "type": [
       "string",
       "null"
      ]
     },
     "renewEveryMonths": {
      "type": [
       "integer",
       "null"
      ]
     },
     "isActive": {
      "type": "boolean",
      "description": "false for a retired item."
     },
     "content": {
      "type": [
       "string",
       "null"
      ],
      "description": "The text to read; up to 20,000 characters."
     },
     "autoAssign": {
      "type": "boolean"
     },
     "dueDays": {
      "type": "integer"
     },
     "proof": {
      "type": "string",
      "description": "What finishing takes; none when nothing is asked."
     },
     "securityAwareness": {
      "type": "boolean"
     },
     "securityAwarenessSince": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "topic": {
      "type": [
       "string",
       "null"
      ],
      "description": "The compliance topic the item counts under."
     },
     "topicLabel": {
      "type": [
       "string",
       "null"
      ]
     },
     "videoUrl": {
      "type": [
       "string",
       "null"
      ]
     },
     "videoRequired": {
      "type": "boolean"
     },
     "hasVideoFile": {
      "type": "boolean"
     },
     "scorm": {
      "type": [
       "object",
       "null"
      ],
      "description": "{ title:string|null, launch:string, files:integer, bytes:integer, importedAt:string (date-time)|null }."
     },
     "audience": {
      "type": [
       "object",
       "null"
      ],
      "description": "{ kind: \"everyone\" } or { kind: \"rule\", departments:string[], positions:string[], locations:string[] }; null when people are assigned by hand."
     },
     "audienceText": {
      "type": "string",
      "description": "The same audience in words."
     },
     "quiz": {
      "anyOf": [
       {
        "$ref": "#/components/schemas/TrainingQuiz"
       },
       {
        "type": "null"
       }
      ]
     }
    }
   },
   "TrainingQuiz": {
    "description": "A knowledge check as a person sees it: questions and options only.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "passMark": {
      "type": "integer"
     },
     "maxAttempts": {
      "type": [
       "integer",
       "null"
      ],
      "description": "Tries per day; null when unlimited."
     },
     "perTry": {
      "type": [
       "integer",
       "null"
      ],
      "description": "How many questions one try draws from the pool."
     },
     "draw": {
      "type": "string",
      "description": "Present on one person's try: identifies the questions drawn. Send it back with the answers."
     },
     "pool": {
      "type": "integer",
      "description": "Present with draw."
     },
     "questions": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { id, text:string, options:string[], example? }."
     }
    }
   },
   "TrainingAssignment": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "courseId": {
      "type": "integer"
     },
     "userId": {
      "type": "integer"
     },
     "dueDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "status": {
      "type": "string",
      "description": "assigned or completed."
     },
     "completedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "lastCompletedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "expiresAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date",
      "description": "When a completion that has to be renewed runs out."
     },
     "overdue": {
      "type": "boolean",
      "description": "Worked out on each read: assigned, past its due date, and the item is not retired."
     },
     "acknowledgedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "hasCertificate": {
      "type": "boolean"
     },
     "quizTriesLeft": {
      "type": [
       "integer",
       "null"
      ],
      "description": "null when tries are not limited or the item was not loaded."
     },
     "quiz": {
      "anyOf": [
       {
        "$ref": "#/components/schemas/TrainingQuiz"
       },
       {
        "type": "null"
       }
      ],
      "description": "This try's questions, only while the item is still to be done."
     },
     "course": {
      "anyOf": [
       {
        "$ref": "#/components/schemas/TrainingCourse"
       },
       {
        "type": "null"
       }
      ]
     },
     "exemptUntil": {
      "type": "string",
      "format": "date",
      "description": "Present only on an open item the person is excused from; overdue is then false."
     }
    }
   },
   "LearningPath": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "title": {
      "type": "string"
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     },
     "isActive": {
      "type": "boolean",
      "description": "false for a paused path."
     },
     "autoAssign": {
      "type": "boolean"
     },
     "autoAssignFrom": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "steps": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { courseId:integer, position:integer, title:string, isActive:boolean, dueDays:integer }, in order."
     },
     "people": {
      "type": "integer",
      "description": "In the path list only: active members."
     },
     "finished": {
      "type": "integer",
      "description": "In the path list only."
     },
     "percent": {
      "type": "integer",
      "description": "In the path list only: finished as a whole percentage of people."
     }
    }
   },
   "PhishingCampaign": {
    "description": "A practice phishing simulation with its totals.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "title": {
      "type": "string"
     },
     "templateKey": {
      "type": "string"
     },
     "difficulty": {
      "type": [
       "string",
       "null"
      ]
     },
     "subject": {
      "type": "string"
     },
     "status": {
      "type": "string",
      "enum": [
       "draft",
       "pending_approval",
       "approved",
       "sending",
       "sent",
       "cancelled"
      ]
     },
     "createdBy": {
      "type": "string"
     },
     "createdById": {
      "type": [
       "integer",
       "null"
      ]
     },
     "approvedBy": {
      "type": [
       "string",
       "null"
      ]
     },
     "approvedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "sendAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "sentAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "courseId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The training item a person who clicks is pointed to."
     },
     "resultsPrunedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "recipients": {
      "type": "integer"
     },
     "sent": {
      "type": "integer"
     },
     "clicked": {
      "type": "integer"
     },
     "reported": {
      "type": "integer"
     },
     "clickRate": {
      "type": [
       "integer",
       "null"
      ],
      "description": "A whole percentage of emails sent; null when none was sent."
     },
     "reportRate": {
      "type": [
       "integer",
       "null"
      ],
      "description": "A whole percentage of emails sent; null when none was sent."
     }
    }
   },
   "EmployeeProfileUpdate": {
    "description": "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.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "preferredName": {
      "type": [
       "string",
       "null"
      ]
     },
     "legalName": {
      "type": [
       "string",
       "null"
      ]
     },
     "nationality": {
      "type": [
       "string",
       "null"
      ]
     },
     "costCenter": {
      "type": [
       "string",
       "null"
      ]
     },
     "legalEntity": {
      "type": [
       "string",
       "null"
      ]
     },
     "noticePeriod": {
      "type": [
       "string",
       "null"
      ]
     },
     "rightToWorkStatus": {
      "type": [
       "string",
       "null"
      ],
      "enum": [
       "verified",
       "pending",
       "expired",
       "not_required",
       null
      ],
      "description": "Any other value answers 400."
     },
     "hireDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date",
      "description": "A real calendar date, or the answer is 400. The same for every date below."
     },
     "originalHireDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "probationEndDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "contractEndDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "rightToWorkVerifiedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "rightToWorkExpiresAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "dateOfBirth": {
      "type": [
       "string",
       "null"
      ],
      "format": "date",
      "description": "Stored encrypted."
     },
     "homeAddress": {
      "type": [
       "string",
       "null"
      ],
      "description": "At most 1000 characters. Stored encrypted."
     }
    }
   },
   "EmergencyContactInput": {
    "description": "One entry of { contacts: [...] }. A call replaces the whole list: at most 5, kept in the order sent.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string"
     },
     "relationship": {
      "type": "string"
     },
     "phone": {
      "type": "string",
      "description": "At most 40 characters."
     }
    },
    "required": [
     "name"
    ]
   },
   "JobChangeCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "type": {
      "type": "string",
      "enum": [
       "promotion",
       "transfer",
       "manager_change",
       "title_change",
       "other"
      ]
     },
     "effectiveDate": {
      "type": "string",
      "format": "date",
      "description": "Default today. A later date stores the change as scheduled; it is applied on that day."
     },
     "changes": {
      "type": "object",
      "description": "At least one of position, department, level, employmentType, workLocation (strings; null clears) and managerId (a user id; null removes the manager)."
     },
     "reason": {
      "type": "string",
      "description": "At most 1000 characters."
     }
    },
    "required": [
     "type",
     "changes"
    ]
   },
   "MyDetailsUpdate": {
    "description": "What a person may change on their own file. At least one key is needed, or the answer is 400.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "phoneNumber": {
      "type": [
       "string",
       "null"
      ],
      "description": "At most 40 characters."
     },
     "personalEmail": {
      "type": [
       "string",
       "null"
      ],
      "format": "email",
      "description": "Must be an email address and must differ from the work email."
     },
     "preferredName": {
      "type": [
       "string",
       "null"
      ]
     },
     "homeAddress": {
      "type": [
       "string",
       "null"
      ],
      "description": "At most 1000 characters. Stored encrypted."
     }
    }
   },
   "CompensationCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "amount": {
      "type": "number",
      "description": "Greater than 0 and at most 10000000000."
     },
     "currency": {
      "type": "string",
      "description": "Three letters; upper-cased."
     },
     "effectiveDate": {
      "type": "string",
      "format": "date",
      "description": "Default today."
     },
     "payPeriod": {
      "type": "string",
      "enum": [
       "annual",
       "monthly",
       "hourly"
      ],
      "description": "Default annual."
     },
     "bonusPct": {
      "type": "number",
      "description": "0 to 999."
     },
     "reason": {
      "type": "string"
     }
    },
    "required": [
     "amount",
     "currency"
    ]
   },
   "EmployeeDocumentWrite": {
    "description": "The entry a file is then uploaded to. An update (PUT on the document) takes the same keys and changes only those sent.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string",
      "description": "Characters that cannot be in a file name become spaces; cut to 180 characters."
     },
     "category": {
      "type": "string",
      "enum": [
       "contract",
       "offer",
       "job_change",
       "policy",
       "certificate",
       "review",
       "letter",
       "other"
      ],
      "description": "Default other."
     },
     "visibility": {
      "type": "string",
      "enum": [
       "hr",
       "employee",
       "manager"
      ],
      "description": "Default hr. employee means HR and the person; manager means HR, the person and their manager."
     },
     "expiresAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     }
    }
   },
   "LeaveRequestCreate": {
    "description": "Whole or half days over a range, or hours on one day when minutes is sent.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "leaveTypeId": {
      "type": "integer",
      "description": "A type that is in use."
     },
     "startDate": {
      "type": "string",
      "format": "date"
     },
     "endDate": {
      "type": "string",
      "format": "date",
      "description": "Default startDate. Not before startDate and at most 366 days after it. With minutes it must be the same day."
     },
     "startHalf": {
      "type": "boolean",
      "description": "The first day is a half day."
     },
     "endHalf": {
      "type": "boolean",
      "description": "The last day is a half day."
     },
     "minutes": {
      "type": "integer",
      "description": "Time off by the hour: at least 15, and it must end on the day it starts."
     },
     "startTime": {
      "type": "string",
      "description": "HH:MM."
     },
     "note": {
      "type": "string",
      "description": "At most 500 characters."
     },
     "repeatWeeks": {
      "type": "integer",
      "description": "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."
     },
     "replaceIds": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "At most 20 of your own bookings by the hour that this whole-day request replaces."
     }
    },
    "required": [
     "leaveTypeId",
     "startDate"
    ]
   },
   "LeaveDecision": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "decision": {
      "type": "string",
      "enum": [
       "approve",
       "decline"
      ]
     },
     "note": {
      "type": "string",
      "description": "At most 500 characters. Shown to the person."
     }
    },
    "required": [
     "decision"
    ]
   },
   "LeaveTypeWrite": {
    "description": "Create and update take the same keys, and an update sets every one of them: a key left out takes its default.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string",
      "description": "At most 60 characters."
     },
     "tracksBalance": {
      "type": "boolean",
      "description": "Default true. When false, annualDays and carryOverMaxDays are stored as 0."
     },
     "annualDays": {
      "type": "number",
      "description": "0 to 366. Default 0."
     },
     "accrual": {
      "type": "string",
      "enum": [
       "upfront",
       "monthly"
      ],
      "description": "Default upfront; any other value is stored as upfront."
     },
     "carryOverMaxDays": {
      "type": "number",
      "description": "0 to 366. Default 0."
     },
     "paid": {
      "type": "boolean",
      "description": "Default true."
     },
     "requiresApproval": {
      "type": "boolean",
      "description": "Default true."
     },
     "allowHours": {
      "type": "boolean",
      "description": "Default true."
     },
     "allowNegative": {
      "type": "boolean",
      "description": "Default false."
     },
     "minPartMinutes": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The smallest amount by the hour: 15 to 1440."
     },
     "maxPartMinutes": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The most by the hour in one request: 15 to 1440, not less than minPartMinutes."
     },
     "isActive": {
      "type": "boolean",
      "description": "Default true."
     },
     "sortOrder": {
      "type": "integer",
      "description": "Default 0."
     },
     "accrueFrom": {
      "type": "string",
      "format": "date",
      "description": "Create only: the day balances start to build from. Kept between 1 January of this year and today; default 1 January."
     }
    },
    "required": [
     "name"
    ]
   },
   "HolidayCalendarWrite": {
    "description": "Create and update take the same keys. The holidays sent replace the stored list.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string"
     },
     "location": {
      "type": [
       "string",
       "null"
      ],
      "description": "The work location this calendar applies to."
     },
     "workDays": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "Days of the week as 0 (Sunday) to 6 (Saturday). Default 1 to 5."
     },
     "holidays": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { date:string (YYYY-MM-DD), name:string }, both required. At most 400, and no date twice."
     },
     "hoursPerDay": {
      "type": "number",
      "description": "1 to 24."
     },
     "dayStart": {
      "type": "string",
      "description": "HH:MM."
     },
     "isDefault": {
      "type": "boolean",
      "description": "true makes this the default calendar. The only calendar is always the default."
     }
    },
    "required": [
     "name"
    ]
   },
   "LeaveBalanceAdjust": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "leaveTypeId": {
      "type": "integer",
      "description": "A type that tracks a balance."
     },
     "days": {
      "type": "number",
      "description": "Not 0, between -366 and 366; a negative number removes days."
     },
     "note": {
      "type": "string",
      "description": "Why the balance is changed."
     }
    },
    "required": [
     "leaveTypeId",
     "days",
     "note"
    ]
   },
   "GoalWrite": {
    "description": "Create and update take the same keys; an update changes only the keys you send. A person can have at most 50 goals.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "title": {
      "type": "string"
     },
     "userId": {
      "type": "integer",
      "description": "Create only: whose goal it is. Default yourself."
     },
     "description": {
      "type": [
       "string",
       "null"
      ],
      "description": "At most 2000 characters."
     },
     "period": {
      "type": [
       "string",
       "null"
      ],
      "description": "At most 40 characters."
     },
     "dueDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "status": {
      "type": "string",
      "enum": [
       "on_track",
       "at_risk",
       "done",
       "dropped"
      ],
      "description": "done sets progress to 100."
     },
     "progress": {
      "type": "integer",
      "description": "0 to 100. 100 with no status sets status to done."
     },
     "weight": {
      "type": [
       "integer",
       "null"
      ],
      "description": "0 to 100."
     }
    }
   },
   "ReviewCycleWrite": {
    "description": "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.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string"
     },
     "periodLabel": {
      "type": [
       "string",
       "null"
      ],
      "description": "At most 60 characters."
     },
     "selfDueDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "managerDueDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date",
      "description": "Not before selfDueDate."
     },
     "department": {
      "type": [
       "string",
       "null"
      ],
      "description": "Limits the cycle to one department."
     },
     "questions": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "1 to 12, each { label:string (required), key?:string, help?:string (at most 300 characters) }. The default questions when left out."
     },
     "includeRating": {
      "type": "boolean",
      "description": "Default true."
     },
     "calibration": {
      "type": "boolean",
      "description": "Default false. Takes effect only with includeRating."
     },
     "goalRatings": {
      "type": "boolean",
      "description": "Default false."
     },
     "peerFeedback": {
      "type": "string",
      "enum": [
       "off",
       "reviewer",
       "person"
      ],
      "description": "Default off. Any other value answers 400."
     }
    },
    "required": [
     "name"
    ]
   },
   "SurveyWrite": {
    "description": "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.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "title": {
      "type": "string"
     },
     "intro": {
      "type": [
       "string",
       "null"
      ],
      "description": "At most 2000 characters."
     },
     "department": {
      "type": [
       "string",
       "null"
      ],
      "description": "Limits the survey to one department."
     },
     "closesAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date",
      "description": "Not in the past."
     },
     "questions": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "1 to 20, each { label:string (required, at most 300 characters), type: scale | enps | text (required), key?:string }. The default questions when left out."
     }
    },
    "required": [
     "title"
    ]
   },
   "TrainingCourseWrite": {
    "description": "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.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "title": {
      "type": "string"
     },
     "description": {
      "type": [
       "string",
       "null"
      ],
      "description": "At most 2000 characters."
     },
     "url": {
      "type": [
       "string",
       "null"
      ],
      "description": "An http or https address, at most 1000 characters."
     },
     "content": {
      "type": "string",
      "description": "The text of the course, at most 20000 characters."
     },
     "proof": {
      "type": "string",
      "enum": [
       "none",
       "acknowledge",
       "certificate",
       "quiz"
      ],
      "description": "How a person marks it done. Default none."
     },
     "quiz": {
      "type": "object",
      "description": "The knowledge check. With proof quiz it needs at least one question."
     },
     "renewEveryMonths": {
      "type": [
       "integer",
       "null"
      ],
      "description": "1 to 120."
     },
     "dueDays": {
      "type": "integer",
      "description": "Days to finish, 1 to 365. Default 30."
     },
     "topic": {
      "type": [
       "string",
       "null"
      ],
      "description": "One of the training topics the instance lists."
     },
     "autoAssign": {
      "type": "boolean",
      "description": "Default false."
     },
     "isActive": {
      "type": "boolean",
      "description": "Default true. false retires the course."
     },
     "videoUrl": {
      "type": "string"
     },
     "videoRequired": {
      "type": "boolean",
      "description": "Takes effect only when the course has a video."
     },
     "audience": {
      "type": "object",
      "description": "Who autoAssign applies to."
     }
    },
    "required": [
     "title"
    ]
   },
   "LearningPathWrite": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "title": {
      "type": "string"
     },
     "courseIds": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "2 to 20 courses in order, each once, none retired."
     },
     "description": {
      "type": [
       "string",
       "null"
      ],
      "description": "At most 2000 characters."
     },
     "isActive": {
      "type": "boolean",
      "description": "Default true."
     },
     "autoAssign": {
      "type": "boolean",
      "description": "Default false."
     }
    },
    "required": [
     "title",
     "courseIds"
    ]
   },
   "PhishingCampaignWrite": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "templateKey": {
      "type": "string",
      "description": "One of the built-in templates."
     },
     "title": {
      "type": "string",
      "description": "Cut to 160 characters. Defaults to the name of the template."
     },
     "userIds": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "1 to 2000 active people."
     },
     "courseId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "A training course in use, linked to the simulation."
     },
     "sendAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "description": "When to send."
     }
    },
    "required": [
     "templateKey",
     "userIds"
    ]
   },
   "SensitiveFieldReveal": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "field": {
      "type": "string",
      "enum": [
       "dateOfBirth",
       "homeAddress"
      ],
      "description": "Any other value is refused with 400."
     }
    },
    "required": [
     "field"
    ]
   },
   "HrAuthorityWrite": {
    "description": "Which system holds the people data: this instance, or a connected HR system.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "mode": {
      "type": "string",
      "enum": [
       "zonitix",
       "external"
      ],
      "description": "Any other value is refused with 400."
     },
     "providerId": {
      "type": "integer",
      "description": "A connected HR system; any other id is refused with 400. Not read for zonitix."
     },
     "fields": {
      "type": "object",
      "description": "Per field: the field key, and zonitix or external as its value."
     }
    },
    "required": [
     "mode"
    ]
   },
   "HrRetentionWrite": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "months": {
      "type": "integer",
      "description": "0 switches retention off; otherwise a whole number from 6 to 240. Anything else is refused with 400."
     }
    },
    "required": [
     "months"
    ]
   },
   "CompensationVoid": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reason": {
      "type": "string",
      "description": "Cut to 255 characters; blank is refused with 400."
     }
    },
    "required": [
     "reason"
    ]
   },
   "LeaveApproveMany": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "ids": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "1 to 50 request ids. An id sent twice is handled once."
     }
    },
    "required": [
     "ids"
    ]
   },
   "LeaveDeclineItem": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "description": "A request id."
     },
     "note": {
      "type": "string",
      "description": "The reason the person reads; cut to 500 characters."
     }
    },
    "required": [
     "id",
     "note"
    ]
   },
   "LeaveDeclineMany": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "items": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/LeaveDeclineItem"
      },
      "description": "1 to 50 items. When any item has no note, the whole call is refused with 400 and nothing is declined."
     }
    },
    "required": [
     "items"
    ]
   },
   "FeedbackRequest": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "userIds": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "Active colleagues; not the person reviewed and not the reviewer."
     }
    },
    "required": [
     "userIds"
    ]
   },
   "ReviewerReassign": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reviewerId": {
      "type": "integer",
      "description": "An active person other than the person reviewed."
     }
    },
    "required": [
     "reviewerId"
    ]
   },
   "TrainingComplete": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "acknowledged": {
      "type": "boolean",
      "description": "Must be true when you finish your own item that asks you to confirm you have read it."
     },
     "reason": {
      "type": "string",
      "description": "Read when HR marks an item done for someone else; cut to 500 characters."
     }
    }
   },
   "LibraryAdopt": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "replace": {
      "type": "boolean",
      "description": "true gives a training item you already have under the same title the library text and knowledge check. Only the value true counts."
     }
    }
   },
   "TrainingAssign": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "audience": {
      "type": "string",
      "enum": [
       "everyone",
       "department",
       "people"
      ],
      "description": "Any other value is refused with 400."
     },
     "department": {
      "type": "string"
     },
     "userIds": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "At most 2,000 ids."
     },
     "dueDate": {
      "type": "string",
      "format": "date",
      "description": "Default: the item's days to finish, counted from today."
     }
    },
    "required": [
     "audience"
    ]
   },
   "TrainingBatchFilter": {
    "description": "Selects every assignment on the roster that matches, in place of a list of ids.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "courseId": {
      "type": "integer"
     },
     "department": {
      "type": "string"
     },
     "status": {
      "type": "string"
     }
    }
   },
   "TrainingBatchAction": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "action": {
      "type": "string",
      "enum": [
       "remove",
       "due",
       "remind",
       "complete"
      ],
      "description": "Any other value is refused with 400."
     },
     "ids": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "At most 2,000 assignment ids. When empty or left out, filter is read."
     },
     "filter": {
      "allOf": [
       {
        "$ref": "#/components/schemas/TrainingBatchFilter"
       }
      ],
      "description": "Read only when ids is empty or left out."
     },
     "dueDate": {
      "type": "string",
      "format": "date"
     },
     "reason": {
      "type": "string",
      "description": "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."
     }
    },
    "required": [
     "action"
    ]
   },
   "CompletionRow": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "email": {
      "type": "string",
      "description": "Matched to an active person without regard to letter case. An address listed twice is used once."
     },
     "completedAt": {
      "type": "string",
      "format": "date",
      "description": "YYYY-MM-DD; not in the future and not more than 5 years ago."
     }
    },
    "required": [
     "email",
     "completedAt"
    ]
   },
   "CompletionsImport": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "source": {
      "type": "string",
      "description": "Where the completions come from; at least 3 characters, cut to 200."
     },
     "rows": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/CompletionRow"
      },
      "description": "1 to 5,000 rows. A row that cannot be used is listed in skipped with its reason."
     }
    },
    "required": [
     "source",
     "rows"
    ]
   },
   "PathMembersAdd": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "userIds": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "At most 500 people. Send this or userId."
     },
     "userId": {
      "type": "integer",
      "description": "One person; read only when userIds is not a list."
     }
    }
   },
   "PhishingReport": {
    "description": "Says which practice email you are reporting. Only your own practice email can match; nothing you send is stored.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "token": {
      "type": "string",
      "description": "The link in the email, or the 64-character code at the end of it."
     },
     "campaign": {
      "type": "integer",
      "description": "A simulation id; read when token is absent or not a valid code."
     }
    }
   },
   "JobPosting": {
    "description": "A requisition.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "title": {
      "type": "string"
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     },
     "department": {
      "type": [
       "string",
       "null"
      ]
     },
     "status": {
      "type": "string",
      "enum": [
       "Draft",
       "Open",
       "Published",
       "Closed",
       "Filled",
       "On Hold"
      ],
      "description": "Only Published roles are on the careers site."
     },
     "financeApprovalStatus": {
      "type": "string",
      "enum": [
       "Pending",
       "Approved",
       "Rejected"
      ],
      "readOnly": true,
      "description": "The headcount budget gate."
     },
     "location": {
      "type": [
       "string",
       "null"
      ]
     },
     "employmentType": {
      "type": [
       "string",
       "null"
      ]
     },
     "seniority": {
      "type": [
       "string",
       "null"
      ]
     },
     "workMode": {
      "type": [
       "string",
       "null"
      ]
     },
     "minExperienceMonths": {
      "type": [
       "integer",
       "null"
      ]
     },
     "currency": {
      "type": [
       "string",
       "null"
      ]
     },
     "salaryRange": {
      "type": [
       "string",
       "null"
      ]
     },
     "salaryMin": {
      "type": [
       "integer",
       "null"
      ]
     },
     "salaryMax": {
      "type": [
       "integer",
       "null"
      ]
     },
     "payPeriod": {
      "type": [
       "string",
       "null"
      ]
     },
     "competencies": {
      "type": "array"
     },
     "requirements": {
      "type": "array"
     },
     "applicationQuestions": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each has id, label, type, required and options on the careers site."
     },
     "hiringManagerId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "ownerId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "personaId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "createdById": {
      "type": [
       "integer",
       "null"
      ],
      "readOnly": true
     },
     "financeApprovedById": {
      "type": [
       "integer",
       "null"
      ],
      "readOnly": true
     },
     "financeApprovedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "financeRejectionReason": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "financeRequestedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "isTalentPool": {
      "type": "boolean",
      "readOnly": true,
      "description": "true only for the talent-pool requisition, which the list leaves out."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "candidateCount": {
      "type": "integer",
      "readOnly": true,
      "description": "In the list only: every applicant, not the preview."
     },
     "stageCounts": {
      "type": [
       "object",
       "null"
      ],
      "readOnly": true,
      "description": "In the list only: { \"<STAGE IN CAPITALS>\": integer }; null when the role has no candidates."
     },
     "Candidates": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "readOnly": true,
      "description": "In the list only: the newest applicants in your own candidate scope, each { id, firstName, lastName, email, stage, matchScore, jobPostingId }."
     },
     "HiringManager": {
      "x-catalogue-type": "{ id:integer, firstName:string|null, lastName:string|null, email:string }",
      "readOnly": true,
      "description": "In the list only. Owner, Creator and FinanceApprover have the same shape."
     },
     "OfferTemplates": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "readOnly": true,
      "description": "In the list only: each { id:integer, name:string, createdAt }."
     },
     "InterviewTemplates": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "readOnly": true,
      "description": "In the list only: each { id:integer, name:string, roundName:string|null, createdAt }."
     }
    }
   },
   "Candidate": {
    "description": "One application to one requisition.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "firstName": {
      "type": "string"
     },
     "lastName": {
      "type": "string"
     },
     "email": {
      "type": "string"
     },
     "phoneNumber": {
      "type": [
       "string",
       "null"
      ]
     },
     "resumeUrl": {
      "type": [
       "string",
       "null"
      ],
      "description": "The stored file name; fetch the file with GET /recruitment/candidates/:id/resume."
     },
     "source": {
      "type": [
       "string",
       "null"
      ]
     },
     "stage": {
      "type": "string",
      "enum": [
       "Sourced",
       "Screen",
       "Interview",
       "Offer",
       "Hired",
       "Rejected"
      ]
     },
     "matchScore": {
      "type": [
       "integer",
       "null"
      ]
     },
     "aiSummary": {
      "type": [
       "string",
       "null"
      ]
     },
     "scoreSource": {
      "type": [
       "string",
       "null"
      ],
      "description": "processing until the first screen has finished."
     },
     "calibrationResult": {
      "type": [
       "object",
       "null"
      ],
      "description": "null for an interviewer who has not yet submitted their own scorecard."
     },
     "parsedProfile": {
      "type": [
       "object",
       "null"
      ]
     },
     "rejectionEmailSent": {
      "type": "boolean"
     },
     "onboardingData": {
      "type": "object"
     },
     "applicationAnswers": {
      "type": "array"
     },
     "knockoutFlag": {
      "type": "boolean"
     },
     "knockoutReasons": {
      "type": "array"
     },
     "jobPostingId": {
      "type": "integer"
     },
     "userId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The linked user account, when there is one."
     },
     "ownerId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "consentAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "humanReviewRequested": {
      "type": "boolean"
     },
     "aiNoticeAckAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "tags": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Each up to 40 characters."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "technicalFitScore": {
      "type": [
       "integer",
       "null"
      ],
      "readOnly": true,
      "description": "In the list only: 0 to 100 from the completed scorecards; null without one."
     },
     "Job": {
      "allOf": [
       {
        "$ref": "#/components/schemas/JobPosting"
       }
      ],
      "readOnly": true,
      "description": "In the list only, with HiringManager."
     },
     "Owner": {
      "x-catalogue-type": "{ id:integer, firstName:string|null, lastName:string|null, email:string }",
      "readOnly": true,
      "description": "In the list only."
     },
     "Offer": {
      "anyOf": [
       {
        "$ref": "#/components/schemas/Offer"
       },
       {
        "type": "null"
       }
      ],
      "readOnly": true,
      "description": "In the list only."
     },
     "Interviews": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/Interview"
      },
      "readOnly": true,
      "description": "In the list only."
     }
    }
   },
   "Offer": {
    "description": "A candidate has at most one. The signing link and the candidate-portal link are never part of a reply.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "candidateId": {
      "type": "integer"
     },
     "salary": {
      "type": [
       "number",
       "null"
      ],
      "description": "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."
     },
     "signOnBonus": {
      "type": [
       "string",
       "null"
      ],
      "description": "In the candidate list only, under the same rule as salary."
     },
     "currency": {
      "type": [
       "string",
       "null"
      ]
     },
     "payPeriod": {
      "type": [
       "string",
       "null"
      ]
     },
     "level": {
      "type": [
       "string",
       "null"
      ]
     },
     "targetBonusPct": {
      "type": [
       "integer",
       "null"
      ]
     },
     "startDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "status": {
      "type": "string",
      "enum": [
       "Draft",
       "Sent",
       "Accepted",
       "Declined"
      ]
     },
     "declineReason": {
      "type": [
       "string",
       "null"
      ]
     },
     "signedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "signedManuallyBy": {
      "type": [
       "integer",
       "null"
      ]
     },
     "approvalStatus": {
      "type": "string",
      "enum": [
       "Pending",
       "Approved",
       "Rejected"
      ]
     },
     "approverId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "termsSetBy": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The person an approval is refused from."
     },
     "offerTemplateId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "supportingDocs": {
      "type": "array",
      "items": {
       "type": "object"
      }
     },
     "documentUrl": {
      "type": [
       "string",
       "null"
      ]
     },
     "docusealSubmissionId": {
      "type": [
       "string",
       "null"
      ],
      "description": "The signing submission, once the offer has been sent."
     },
     "rescindStatus": {
      "type": [
       "string",
       "null"
      ]
     },
     "rescindRequestedBy": {
      "type": [
       "integer",
       "null"
      ]
     },
     "rescindReason": {
      "type": [
       "string",
       "null"
      ]
     },
     "rescindApprovedBy": {
      "type": [
       "integer",
       "null"
      ]
     },
     "tokenExpiresAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "Template": {
      "anyOf": [
       {
        "$ref": "#/components/schemas/OfferTemplate"
       },
       {
        "type": "null"
       }
      ],
      "readOnly": true,
      "description": "In the candidate list only."
     },
     "Approver": {
      "x-catalogue-type": "{ id:integer, firstName:string|null, lastName:string|null, email:string }",
      "readOnly": true,
      "description": "In the candidate list only."
     }
    }
   },
   "Interview": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "candidateId": {
      "type": "integer"
     },
     "interviewerId": {
      "type": "integer"
     },
     "scheduledAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "description": "null while the candidate has not yet picked one of the proposed times."
     },
     "feedback": {
      "type": [
       "string",
       "null"
      ],
      "description": "\"Hidden from your view\" on another interviewer's row until you have submitted your own scorecard."
     },
     "structuredFeedback": {
      "type": [
       "object",
       "null"
      ],
      "description": "null on a masked row."
     },
     "score": {
      "type": [
       "integer",
       "null"
      ],
      "description": "1 to 5; null on a masked row."
     },
     "status": {
      "type": "string",
      "enum": [
       "Scheduled",
       "Completed",
       "Cancelled"
      ]
     },
     "externalEventId": {
      "type": [
       "string",
       "null"
      ]
     },
     "icsSequence": {
      "type": "integer"
     },
     "cancelledAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "cancelReason": {
      "type": [
       "string",
       "null"
      ]
     },
     "sequence": {
      "type": [
       "integer",
       "null"
      ]
     },
     "isShadow": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "A shadow interviewer's score does not count towards technicalFitScore."
     },
     "interviewTemplateId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "proposedSlots": {
      "type": "array",
      "description": "The times offered for self-scheduling."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "masked": {
      "type": "boolean",
      "description": "true on a row whose score and feedback were hidden from you."
     },
     "Interviewer": {
      "x-catalogue-type": "{ id:integer, firstName:string|null, lastName:string|null, email:string }",
      "readOnly": true,
      "description": "In the candidate list only."
     },
     "Template": {
      "anyOf": [
       {
        "$ref": "#/components/schemas/InterviewTemplate"
       },
       {
        "type": "null"
       }
      ],
      "readOnly": true,
      "description": "In the candidate list only, with its Questions."
     }
    }
   },
   "InterviewTemplate": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "name": {
      "type": "string"
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     },
     "roundName": {
      "type": [
       "string",
       "null"
      ]
     },
     "jobPostingId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "null for a template that is not tied to one requisition."
     },
     "sourceJobId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "sourceJobTitle": {
      "type": [
       "string",
       "null"
      ]
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "Questions": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/InterviewQuestion"
      },
      "description": "In order."
     },
     "usageCount": {
      "type": "integer",
      "readOnly": true,
      "description": "In the list only."
     }
    }
   },
   "InterviewQuestion": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "interviewTemplateId": {
      "type": "integer"
     },
     "questionText": {
      "type": "string"
     },
     "category": {
      "type": [
       "string",
       "null"
      ]
     },
     "context": {
      "type": [
       "string",
       "null"
      ]
     },
     "idealAnswer": {
      "type": [
       "string",
       "null"
      ]
     },
     "weight": {
      "type": [
       "integer",
       "null"
      ],
      "description": "Default 1."
     },
     "mustHave": {
      "type": [
       "boolean",
       "null"
      ]
     },
     "anchors": {
      "type": [
       "object",
       "null"
      ]
     },
     "order": {
      "type": [
       "integer",
       "null"
      ]
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "OfferTemplate": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "name": {
      "type": "string"
     },
     "body": {
      "type": "string"
     },
     "docusealTemplateId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "jobPostingId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "sourceJobId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "sourceJobTitle": {
      "type": [
       "string",
       "null"
      ]
     },
     "supportingDocs": {
      "type": "array",
      "items": {
       "type": "object"
      }
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "usageCount": {
      "type": "integer",
      "readOnly": true,
      "description": "In the list only."
     }
    }
   },
   "CandidateNote": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "candidateId": {
      "type": "integer"
     },
     "authorId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "body": {
      "type": "string"
     },
     "mentions": {
      "type": "array"
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "Author": {
      "x-catalogue-type": "{ id:integer, firstName:string|null, lastName:string|null, email:string }",
      "readOnly": true,
      "description": "In the list only."
     }
    }
   },
   "CandidateMessage": {
    "description": "One email to or from a candidate, as the message list returns it.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "direction": {
      "type": "string",
      "enum": [
       "outbound",
       "inbound"
      ]
     },
     "fromAddr": {
      "type": [
       "string",
       "null"
      ]
     },
     "toAddr": {
      "type": [
       "string",
       "null"
      ]
     },
     "subject": {
      "type": [
       "string",
       "null"
      ]
     },
     "body": {
      "type": [
       "string",
       "null"
      ]
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "sentById": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The user who sent an outbound message."
     }
    }
   },
   "Referral": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "userId": {
      "type": "integer",
      "description": "The employee who made the referral."
     },
     "candidateId": {
      "type": "integer"
     },
     "notes": {
      "type": [
       "string",
       "null"
      ]
     },
     "status": {
      "type": "string",
      "enum": [
       "Pending",
       "Reviewed",
       "Hired",
       "Rejected"
      ]
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "Candidate": {
      "type": "object",
      "readOnly": true,
      "description": "In the list only: { id, firstName, lastName, stage, source, createdAt, Job:{ id, title, department } }."
     }
    }
   },
   "JobPostingWrite": {
    "description": "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.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "title": {
      "type": "string"
     },
     "description": {
      "type": "string"
     },
     "department": {
      "type": "string"
     },
     "status": {
      "type": "string",
      "enum": [
       "Draft",
       "Open",
       "Published",
       "Closed",
       "Filled",
       "On Hold"
      ],
      "description": "Open and Published are refused with 409 until the headcount budget is approved."
     },
     "location": {
      "type": "string"
     },
     "employmentType": {
      "type": "string"
     },
     "seniority": {
      "type": "string"
     },
     "workMode": {
      "type": "string"
     },
     "minExperienceMonths": {
      "type": [
       "integer",
       "null"
      ],
      "description": "A number, or text that is a number: rounded, 0 to 2147483647. Blank is stored as empty; anything else answers 400 INVALID_NUMBER."
     },
     "currency": {
      "type": "string"
     },
     "salaryRange": {
      "type": "string"
     },
     "salaryMin": {
      "type": [
       "integer",
       "null"
      ],
      "description": "Read the same way as minExperienceMonths."
     },
     "salaryMax": {
      "type": [
       "integer",
       "null"
      ],
      "description": "Read the same way as minExperienceMonths."
     },
     "payPeriod": {
      "type": "string"
     },
     "competencies": {
      "type": "array"
     },
     "requirements": {
      "type": "array"
     },
     "applicationQuestions": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "The extra questions on the public application form."
     },
     "hiringManagerId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "ownerId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "personaId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "Assigning a persona is checked against what you are allowed to grant."
     }
    }
   },
   "CandidateCreate": {
    "description": "A candidate added by a signed-in recruiter. The stage, the scores and the consent fields are set by the server.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "jobPostingId": {
      "type": "integer"
     },
     "email": {
      "type": "string",
      "format": "email",
      "description": "Stored in one normalised spelling. The same email on the same requisition answers 409."
     },
     "firstName": {
      "type": "string"
     },
     "lastName": {
      "type": "string"
     },
     "phoneNumber": {
      "type": "string",
      "description": "Stored encrypted."
     },
     "source": {
      "type": "string"
     },
     "ownerId": {
      "type": "integer",
      "description": "Read only from a caller who holds manage_recruitment. Otherwise the candidate takes the owner of the requisition, or you when it has none."
     }
    },
    "required": [
     "jobPostingId",
     "email",
     "firstName",
     "lastName"
    ]
   },
   "CandidateBulkAction": {
    "description": "One action for many candidates. Each candidate is handled on its own and lands in succeeded, skipped or failed.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "ids": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "1 to 200 candidate ids."
     },
     "action": {
      "type": "string",
      "enum": [
       "stage",
       "reject",
       "reassign",
       "tag"
      ]
     },
     "stage": {
      "type": "string",
      "enum": [
       "Sourced",
       "Screen",
       "Interview",
       "Offer",
       "Hired",
       "Rejected"
      ],
      "description": "Any capitalisation."
     },
     "sendEmail": {
      "type": "boolean",
      "description": "reject only: the rejection email is sent unless this is false."
     },
     "reason": {
      "type": "string",
      "description": "reject only."
     },
     "ownerId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "reassign only."
     },
     "add": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "tag only: labels to add."
     },
     "remove": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "tag only: labels to remove."
     }
    },
    "required": [
     "ids",
     "action"
    ]
   },
   "CandidateTagsUpdate": {
    "description": "A label is trimmed and cut to 40 characters; labels that differ only in capitalisation count as one; a candidate keeps at most 30.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "add": {
      "type": "array",
      "items": {
       "type": "string"
      }
     },
     "remove": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Matched without regard to capitalisation."
     }
    }
   },
   "CandidateNoteCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "body": {
      "type": "string",
      "description": "Trimmed and cut to 5000 characters; empty answers 400."
     },
     "mentions": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "User ids to notify. Values that are not positive whole numbers, and repeats, are dropped."
     }
    },
    "required": [
     "body"
    ]
   },
   "CandidateMessageSend": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "subject": {
      "type": "string",
      "description": "Trimmed and cut to 500 characters."
     },
     "body": {
      "type": "string",
      "description": "Trimmed. A blank line starts a new paragraph in the email."
     }
    },
    "required": [
     "subject",
     "body"
    ]
   },
   "InterviewCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "interviewerId": {
      "type": "integer",
      "description": "A person who is allowed to conduct interviews."
     },
     "scheduledAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "description": "Text that is not a date answers 400 INVALID_TIME. Without it the interview is created with no time."
     },
     "sequence": {
      "type": "integer",
      "description": "The round number. Default 1."
     },
     "interviewTemplateId": {
      "type": "integer"
     },
     "isShadow": {
      "type": "boolean",
      "description": "Default false."
     },
     "durationMins": {
      "type": "integer",
      "description": "Used when checking the interviewer's other bookings."
     },
     "allowOverlap": {
      "type": "boolean",
      "description": "true books the time although the interviewer already has an interview then."
     }
    },
    "required": [
     "interviewerId"
    ]
   },
   "InterviewSlotsPropose": {
    "description": "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.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "interviewerId": {
      "type": "integer",
      "description": "400 INTERVIEWER_REQUIRED without it."
     },
     "slots": {
      "type": "array",
      "items": {
       "type": "string",
       "format": "date-time"
      },
      "description": "At least two usable times, or the answer is 400 NOT_ENOUGH_SLOTS."
     },
     "interviewTemplateId": {
      "type": "integer"
     },
     "durationMins": {
      "type": "integer"
     },
     "allowOverlap": {
      "type": "boolean",
      "description": "Without it, times that clash with the interviewer's bookings are dropped, and fewer than two left answers 409 DOUBLE_BOOKED."
     },
     "isShadow": {
      "type": "boolean",
      "description": "Default false."
     }
    },
    "required": [
     "interviewerId",
     "slots"
    ]
   },
   "ScorecardSubmit": {
    "description": "Marks the interview Completed.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "score": {
      "type": "integer",
      "description": "1 to 5; anything else answers 400 INVALID_SCORE."
     },
     "feedback": {
      "type": "string"
     },
     "structuredFeedback": {
      "type": "object",
      "description": "Your marks and notes per competency. The recommendation is carried inside it."
     }
    }
   },
   "InterviewQuestionInput": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "questionText": {
      "type": "string"
     },
     "category": {
      "type": "string"
     },
     "context": {
      "type": "string"
     },
     "idealAnswer": {
      "type": "string"
     },
     "weight": {
      "type": "integer",
      "description": "Default 1."
     },
     "mustHave": {
      "type": "boolean",
      "description": "Default false."
     },
     "anchors": {
      "type": "object"
     },
     "order": {
      "type": "integer",
      "description": "Defaults to the position in the list."
     }
    },
    "required": [
     "questionText"
    ]
   },
   "InterviewTemplateWrite": {
    "description": "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.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string"
     },
     "description": {
      "type": "string"
     },
     "roundName": {
      "type": "string"
     },
     "jobPostingId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "null makes the template available to every requisition."
     },
     "questions": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/InterviewQuestionInput"
      }
     }
    }
   },
   "OfferTemplateWrite": {
    "description": "Create and update take the same keys; an update changes only the keys you send.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string"
     },
     "body": {
      "type": "string",
      "description": "The text of the offer letter."
     },
     "jobPostingId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "null makes the template available to every requisition."
     },
     "supportingDocs": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "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."
     }
    }
   },
   "OfferTermsWrite": {
    "description": "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.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "salary": {
      "type": [
       "number",
       "null"
      ],
      "description": "Not negative, or the answer is 400."
     },
     "currency": {
      "type": [
       "string",
       "null"
      ]
     },
     "payPeriod": {
      "type": [
       "string",
       "null"
      ]
     },
     "startDate": {
      "type": "string",
      "format": "date",
      "description": "YYYY-MM-DD, or the answer is 400."
     },
     "level": {
      "type": [
       "string",
       "null"
      ]
     },
     "targetBonusPct": {
      "type": [
       "integer",
       "null"
      ],
      "description": "0 to 100; rounded to a whole number."
     },
     "signOnBonus": {
      "type": [
       "number",
       "null"
      ],
      "description": "Not negative."
     },
     "hiringManagerId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "Written to the requisition, not the offer."
     }
    }
   },
   "OfferBulkDecision": {
    "description": "Approve or refuse many offers. Each one goes through the same checks as the single routes and lands in succeeded or failed.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "ids": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "1 to 200 candidate ids."
     },
     "approve": {
      "type": "boolean",
      "description": "Only true approves; any other value refuses."
     },
     "reason": {
      "type": "string",
      "description": "Stored with a refusal."
     },
     "termsVersions": {
      "type": "object",
      "description": "{ \"<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."
     }
    },
    "required": [
     "ids",
     "approve"
    ]
   },
   "RequisitionOwnerWrite": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "ownerId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "A user id. null, 0 or a missing key leaves the requisition without an owner."
     },
     "cascade": {
      "type": "boolean",
      "description": "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."
     }
    }
   },
   "CandidateOwnerWrite": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "ownerId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "A user id. null, 0 or a missing key leaves the candidate without an owner."
     }
    }
   },
   "BudgetRefusal": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reason": {
      "type": "string",
      "description": "Trimmed; at least 3 characters, or the answer is 400 with code REASON_REQUIRED. Cut to 1000 characters and stored on the requisition."
     }
    },
    "required": [
     "reason"
    ]
   },
   "CompetencySuggest": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "jobTitle": {
      "type": "string",
      "description": "Blank is refused with 400."
     },
     "jobDescription": {
      "type": "string"
     }
    },
    "required": [
     "jobTitle"
    ]
   },
   "JobDescriptionSuggest": {
    "description": "The whole body is passed to the model as context, so the other fields of the requisition form may be sent with it.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "jobTitle": {
      "type": "string",
      "description": "Blank is refused with 400."
     }
    },
    "required": [
     "jobTitle"
    ]
   },
   "CandidateStageWrite": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "stage": {
      "type": "string",
      "description": "Sourced, Screen, Interview, Offer, Hired or Rejected, in any capitalisation."
     }
    },
    "required": [
     "stage"
    ]
   },
   "CandidateReject": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "sendEmail": {
      "type": "boolean",
      "description": "Whether the candidate is sent the rejection email."
     },
     "reason": {
      "type": "string"
     }
    }
   },
   "InterviewReschedule": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "scheduledAt": {
      "type": "string",
      "format": "date-time",
      "description": "Text that is not a date is refused with 400, code INVALID_TIME."
     },
     "durationMins": {
      "type": "integer"
     },
     "allowOverlap": {
      "type": "boolean",
      "description": "Only the value true books the time although the interviewer already has an interview then."
     }
    },
    "required": [
     "scheduledAt"
    ]
   },
   "InterviewCancel": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reason": {
      "type": "string",
      "description": "Cut to 500 characters."
     }
    }
   },
   "DraftForRequisition": {
    "description": "Asks for a generated draft: interview questions, or an offer template.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "jobPostingId": {
      "type": "integer",
      "description": "The requisition used as context. Without it the draft is written for a general role."
     }
    }
   },
   "OfferGatePolicyWrite": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "requireScorecardsBeforeOffer": {
      "type": "boolean",
      "description": "Anything that is not true or false is refused with 400."
     }
    },
    "required": [
     "requireScorecardsBeforeOffer"
    ]
   },
   "OfferApprove": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "termsVersion": {
      "type": "string",
      "description": "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."
     }
    }
   },
   "OfferReject": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reason": {
      "type": "string"
     }
    }
   },
   "OfferDeclineReason": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reason": {
      "type": "string",
      "description": "Trimmed and cut to 2000 characters. Blank, or anything that is not text, clears the stored reason."
     }
    }
   },
   "OfferRescindRequest": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reason": {
      "type": "string",
      "description": "Blank is refused with 400, code REASON_REQUIRED."
     }
    },
    "required": [
     "reason"
    ]
   },
   "OfferSend": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "offerTemplateId": {
      "x-catalogue-type": "integer | null",
      "description": "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."
     }
    }
   },
   "AssistantQuery": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "query": {
      "type": "string",
      "description": "The question. The streaming route refuses a blank one with 400."
     }
    },
    "required": [
     "query"
    ]
   },
   "FeedbackRefine": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "text": {
      "type": "string",
      "description": "The feedback text to tidy."
     }
    },
    "required": [
     "text"
    ]
   },
   "JobAlertSubscribe": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "email": {
      "type": "string",
      "description": "Trimmed, lower-cased and cut to 200 characters; must look like an address."
     },
     "consent": {
      "type": "boolean",
      "description": "Must be true."
     },
     "department": {
      "type": "string",
      "description": "Cut to 120 characters. Without it the alert covers every department."
     },
     "locale": {
      "type": "string",
      "description": "Cut to 8 characters."
     }
    },
    "required": [
     "email",
     "consent"
    ]
   },
   "JobAlertToken": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "token": {
      "type": "string",
      "description": "The token from the link in the email."
     }
    },
    "required": [
     "token"
    ]
   },
   "SelfScheduleChoice": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "chosen": {
      "type": "string",
      "format": "date-time",
      "description": "One of the times offered by the GET on the same path; any other time is refused with 400, code SLOT_NOT_OFFERED."
     }
    },
    "required": [
     "chosen"
    ]
   },
   "Invoice": {
    "description": "One row of GET /parser/invoices.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "db_id": {
      "type": "integer",
      "description": "The same number as id; the path parameter of every route that takes :doc_id."
     },
     "displayId": {
      "type": "string",
      "description": "INV- followed by the id, padded to three digits."
     },
     "filename": {
      "type": [
       "string",
       "null"
      ]
     },
     "vendor": {
      "type": [
       "string",
       "null"
      ],
      "description": "The vendor name as read from the document."
     },
     "canonicalVendor": {
      "type": [
       "string",
       "null"
      ],
      "description": "The name of the vendor record it is linked to; otherwise the same as vendor."
     },
     "vendorId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "amount": {
      "type": "number",
      "description": "In the invoice's own currency; 0 when none was read."
     },
     "currency": {
      "type": "string",
      "description": "A 3-letter code; USD when none is stored."
     },
     "currencyDefaulted": {
      "type": "boolean",
      "description": "true when no currency was on the document and your base currency was assumed."
     },
     "amountBase": {
      "type": "number",
      "description": "The amount in your base currency; the entered amount when no conversion is stored."
     },
     "date": {
      "type": "string",
      "description": "A display date such as \"Oct 10, 2026\", or \"N/A\". Use dateIso in code."
     },
     "dateIso": {
      "type": "string",
      "format": "date-time"
     },
     "renewalDate": {
      "type": "string",
      "description": "A display date, or \"N/A\"."
     },
     "category": {
      "type": [
       "string",
       "null"
      ]
     },
     "costCenter": {
      "type": [
       "string",
       "null"
      ]
     },
     "department": {
      "type": [
       "string",
       "null"
      ]
     },
     "project": {
      "type": [
       "string",
       "null"
      ]
     },
     "status": {
      "type": "string",
      "description": "The payment status; Pending when none is stored."
     },
     "approvalStatus": {
      "type": "string",
      "description": "Draft when none is stored. See the approval states on the Finance Operations page."
     },
     "extractionConfidence": {
      "type": [
       "number",
       "null"
      ]
     },
     "invoiceNumber": {
      "type": [
       "string",
       "null"
      ]
     },
     "taxAmount": {
      "type": [
       "number",
       "null"
      ]
     },
     "poNumber": {
      "type": [
       "string",
       "null"
      ]
     },
     "glCode": {
      "type": [
       "string",
       "null"
      ]
     },
     "customFields": {
      "type": "object",
      "description": "Your own allocation fields; {} when none is set."
     },
     "matchStatus": {
      "type": "string",
      "description": "unmatched when no match has run."
     },
     "submittedBy": {
      "type": [
       "integer",
       "null"
      ]
     },
     "approvedBy": {
      "type": [
       "integer",
       "null"
      ]
     },
     "paidBy": {
      "type": [
       "integer",
       "null"
      ]
     },
     "rejectionReason": {
      "type": [
       "string",
       "null"
      ]
     }
    }
   },
   "InvoicePage": {
    "description": "The reply of GET /parser/invoices. Page-numbered: page and limit, no offset.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "items": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/Invoice"
      }
     },
     "total": {
      "type": "integer",
      "description": "Every invoice matching the filters, not the page."
     },
     "page": {
      "type": "integer",
      "description": "Starts at 1."
     },
     "limit": {
      "type": "integer",
      "description": "Default 25, 1 to 200."
     },
     "pages": {
      "type": "integer"
     },
     "filteredTotalAmount": {
      "type": "number",
      "description": "The sum of every matching invoice in your base currency."
     }
    }
   },
   "FinanceVendor": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "canonicalName": {
      "type": "string"
     },
     "aliases": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Other spellings that are matched to this vendor."
     },
     "category": {
      "type": [
       "string",
       "null"
      ]
     },
     "owner": {
      "type": [
       "string",
       "null"
      ]
     },
     "contractRef": {
      "type": [
       "string",
       "null"
      ]
     },
     "contractRenewal": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "notes": {
      "type": [
       "string",
       "null"
      ]
     },
     "customFields": {
      "type": "object",
      "description": "{} when none is set."
     },
     "totalSpend": {
      "type": "number",
      "description": "To two places."
     },
     "mergedIntoId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "Set on a vendor that was merged into another."
     }
    }
   },
   "PurchaseOrder": {
    "description": "One row of GET /parser/finance/purchase-orders.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "poNumber": {
      "type": "string"
     },
     "vendor": {
      "type": [
       "string",
       "null"
      ]
     },
     "currency": {
      "type": "string",
      "description": "A 3-letter code."
     },
     "totalAmount": {
      "type": "number",
      "description": "In the order's own currency."
     },
     "totalAmountBase": {
      "type": "number",
      "description": "In your base currency: the figure budgets and matching use."
     },
     "baseConverted": {
      "type": "boolean",
      "description": "false when no converted value is stored; totalAmountBase is then the entered figure."
     },
     "status": {
      "type": "string",
      "description": "open until the order is closed."
     },
     "department": {
      "type": [
       "string",
       "null"
      ]
     },
     "costCenter": {
      "type": [
       "string",
       "null"
      ]
     },
     "project": {
      "type": [
       "string",
       "null"
      ]
     },
     "lineCount": {
      "type": "integer",
      "description": "In the list only."
     },
     "grnCount": {
      "type": "integer",
      "description": "In the list only: goods receipts recorded against the order."
     },
     "orderedDate": {
      "type": [
       "string",
       "null"
      ],
      "description": "A display date such as \"Oct 10, 2026\"."
     }
    }
   },
   "MatchException": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "docId": {
      "type": "integer",
      "description": "The invoice."
     },
     "displayId": {
      "type": "string"
     },
     "vendor": {
      "type": [
       "string",
       "null"
      ]
     },
     "poNumber": {
      "type": [
       "string",
       "null"
      ]
     },
     "exceptionType": {
      "type": "string"
     },
     "severity": {
      "type": "string"
     },
     "status": {
      "type": "string",
      "enum": [
       "open",
       "waived",
       "resolved"
      ]
     },
     "expected": {
      "type": [
       "number",
       "null"
      ]
     },
     "actual": {
      "type": [
       "number",
       "null"
      ]
     },
     "variancePct": {
      "type": [
       "number",
       "null"
      ]
     },
     "tolerancePct": {
      "type": [
       "number",
       "null"
      ]
     },
     "reason": {
      "type": [
       "string",
       "null"
      ]
     },
     "createdAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     }
    }
   },
   "ApprovalQueueItem": {
    "description": "One submitted invoice in GET /parser/finance/approvals-queue.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "db_id": {
      "type": "integer",
      "description": "The invoice id."
     },
     "displayId": {
      "type": "string"
     },
     "vendor": {
      "type": [
       "string",
       "null"
      ]
     },
     "amount": {
      "type": "number"
     },
     "amountBase": {
      "type": "number"
     },
     "currency": {
      "type": "string"
     },
     "matchStatus": {
      "type": "string"
     },
     "poNumber": {
      "type": [
       "string",
       "null"
      ]
     },
     "approvalsCount": {
      "type": "integer",
      "description": "Approvals recorded so far."
     },
     "approvalsRequired": {
      "type": "integer",
      "description": "What the approval rule for this amount asks for."
     },
     "approvers": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "The names of the people who have approved."
     },
     "ageDays": {
      "type": [
       "integer",
       "null"
      ],
      "description": "Days since it was submitted."
     },
     "submittedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "highValue": {
      "type": "boolean"
     }
    }
   },
   "ApprovalRule": {
    "description": "How many approvers an invoice of a given amount needs.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "name": {
      "type": "string"
     },
     "minAmount": {
      "type": "number",
      "description": "In your base currency."
     },
     "maxAmount": {
      "type": [
       "number",
       "null"
      ],
      "description": "null for no upper limit."
     },
     "requiredApprovals": {
      "type": "integer"
     },
     "isActive": {
      "type": "boolean"
     }
    }
   },
   "GLAccount": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "code": {
      "type": "string"
     },
     "name": {
      "type": "string"
     },
     "type": {
      "type": [
       "string",
       "null"
      ]
     },
     "isActive": {
      "type": "boolean"
     }
    }
   },
   "Budget": {
    "description": "All the money fields are in your base currency except budgetEntered.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "scopeType": {
      "type": "string",
      "description": "global when the budget covers all spend."
     },
     "scopeValue": {
      "type": [
       "string",
       "null"
      ]
     },
     "label": {
      "type": "string",
      "description": "scopeValue, or \"All spend\"."
     },
     "name": {
      "type": [
       "string",
       "null"
      ]
     },
     "displayName": {
      "type": "string"
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     },
     "budgetType": {
      "type": "string"
     },
     "startDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "endDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "periodKind": {
      "type": [
       "string",
       "null"
      ]
     },
     "authoritativeSource": {
      "type": "string",
      "enum": [
       "invoice",
       "cloud",
       "both"
      ],
      "description": "Which actuals drive consumedPct."
     },
     "currency": {
      "type": [
       "string",
       "null"
      ],
      "description": "The currency the budget was entered in."
     },
     "tags": {
      "type": "array"
     },
     "glAccountId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "recurrence": {
      "type": [
       "string",
       "null"
      ]
     },
     "recurrenceInterval": {
      "type": [
       "string",
       "null"
      ]
     },
     "warnPct": {
      "type": [
       "number",
       "null"
      ],
      "description": "This budget's own warning threshold, as a fraction."
     },
     "overPct": {
      "type": [
       "number",
       "null"
      ]
     },
     "warnThreshold": {
      "type": "number",
      "description": "The threshold in force, as a fraction."
     },
     "overThreshold": {
      "type": "number"
     },
     "ownerEmail": {
      "type": [
       "string",
       "null"
      ]
     },
     "ownerUserId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "ownerDepartment": {
      "type": [
       "string",
       "null"
      ]
     },
     "period": {
      "type": "string"
     },
     "budget": {
      "type": "number",
      "description": "The budget amount in base currency."
     },
     "budgetEntered": {
      "type": "number",
      "description": "As entered, in currency."
     },
     "baseCurrency": {
      "type": "string"
     },
     "actual": {
      "type": "number",
      "description": "Per authoritativeSource."
     },
     "invoiceActual": {
      "type": "number"
     },
     "cloudActual": {
      "type": "number"
     },
     "cloudSynced": {
      "type": "boolean"
     },
     "cloudSystemCount": {
      "type": "integer"
     },
     "committed": {
      "type": "number",
      "description": "Open purchase-order value that has not been invoiced yet."
     },
     "softCommitted": {
      "type": "number",
      "description": "Pending and approved spend requests."
     },
     "available": {
      "type": "number",
      "description": "budget minus actual, committed and softCommitted."
     },
     "remaining": {
      "type": "number",
      "description": "budget minus actual."
     },
     "variance": {
      "type": "number"
     },
     "baselineBudget": {
      "type": "number",
      "description": "The first version of this budget."
     },
     "varianceVsBaseline": {
      "type": "number"
     },
     "varianceType": {
      "type": "string",
      "enum": [
       "favorable",
       "unfavorable"
      ]
     },
     "consumedPct": {
      "type": "number",
      "description": "A percentage to one place."
     },
     "projected": {
      "type": "number"
     },
     "projectedPct": {
      "type": "number"
     },
     "onPaceToExceed": {
      "type": "boolean"
     },
     "status": {
      "type": "string",
      "enum": [
       "OK",
       "Warning",
       "Over"
      ],
      "description": "The consumption status."
     },
     "lifecycleStatus": {
      "type": "string",
      "description": "Draft, Submitted, Approved or Rejected."
     },
     "pendingReapproval": {
      "type": "boolean"
     },
     "approvedAmountBase": {
      "type": [
       "number",
       "null"
      ]
     },
     "reapprovalEditor": {
      "type": [
       "string",
       "null"
      ]
     },
     "submittedBy": {
      "type": [
       "integer",
       "null"
      ]
     },
     "approvedBy": {
      "type": [
       "integer",
       "null"
      ]
     },
     "rejectionReason": {
      "type": [
       "string",
       "null"
      ]
     },
     "versionNo": {
      "type": "integer"
     },
     "parentBudgetId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "isCurrent": {
      "type": "boolean"
     },
     "locked": {
      "type": "boolean"
     },
     "versionLabel": {
      "type": [
       "string",
       "null"
      ]
     },
     "method": {
      "type": "string"
     },
     "hasLines": {
      "type": "boolean"
     },
     "frozen": {
      "type": "boolean"
     },
     "frozenReason": {
      "type": [
       "string",
       "null"
      ]
     }
    }
   },
   "BudgetLine": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "budgetId": {
      "type": "integer"
     },
     "label": {
      "type": "string"
     },
     "glAccountId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "category": {
      "type": [
       "string",
       "null"
      ]
     },
     "costCenter": {
      "type": [
       "string",
       "null"
      ]
     },
     "amount": {
      "type": "number"
     },
     "justification": {
      "type": [
       "string",
       "null"
      ]
     },
     "driverKey": {
      "type": [
       "string",
       "null"
      ]
     },
     "driverQty": {
      "type": [
       "number",
       "null"
      ]
     },
     "unitCost": {
      "type": [
       "number",
       "null"
      ]
     },
     "sortOrder": {
      "type": "integer"
     }
    }
   },
   "BudgetComment": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "budgetId": {
      "type": "integer"
     },
     "authorId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "author": {
      "type": [
       "string",
       "null"
      ]
     },
     "comment": {
      "type": "string"
     },
     "createdAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     }
    }
   },
   "BudgetTransfer": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "amount": {
      "type": "number"
     },
     "reason": {
      "type": [
       "string",
       "null"
      ]
     },
     "status": {
      "type": "string"
     },
     "currency": {
      "type": [
       "string",
       "null"
      ]
     },
     "amountBase": {
      "type": "number"
     },
     "fromAmount": {
      "type": [
       "number",
       "null"
      ],
      "description": "What leaves the source budget, in its own currency."
     },
     "fromCurrency": {
      "type": [
       "string",
       "null"
      ]
     },
     "toAmount": {
      "type": [
       "number",
       "null"
      ]
     },
     "toCurrency": {
      "type": [
       "string",
       "null"
      ]
     },
     "fromBudgetId": {
      "type": "integer"
     },
     "toBudgetId": {
      "type": "integer"
     },
     "fromLabel": {
      "type": "string"
     },
     "toLabel": {
      "type": "string"
     },
     "requestedBy": {
      "type": [
       "integer",
       "null"
      ]
     },
     "requestedByEmail": {
      "type": [
       "string",
       "null"
      ]
     },
     "requestedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "approvedBy": {
      "type": [
       "integer",
       "null"
      ]
     },
     "approvedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "rejectionReason": {
      "type": [
       "string",
       "null"
      ]
     },
     "approvalsHave": {
      "type": "integer",
      "description": "Distinct approvers recorded so far."
     },
     "approvalsNeed": {
      "type": "integer",
      "description": "What a transfer of this amount requires."
     }
    }
   },
   "SpendRequest": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "requesterEmail": {
      "type": [
       "string",
       "null"
      ]
     },
     "department": {
      "type": [
       "string",
       "null"
      ]
     },
     "budgetId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "budgetName": {
      "type": [
       "string",
       "null"
      ]
     },
     "amount": {
      "type": "number"
     },
     "amountBase": {
      "type": [
       "number",
       "null"
      ]
     },
     "currency": {
      "type": [
       "string",
       "null"
      ]
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     },
     "vendor": {
      "type": [
       "string",
       "null"
      ]
     },
     "status": {
      "type": "string"
     },
     "createdAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "approvedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "rejectionReason": {
      "type": [
       "string",
       "null"
      ]
     }
    }
   },
   "BudgetAlert": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "budgetId": {
      "type": "integer"
     },
     "tier": {
      "type": "string"
     },
     "message": {
      "type": [
       "string",
       "null"
      ]
     },
     "consumedPct": {
      "type": [
       "number",
       "null"
      ]
     },
     "ownerEmail": {
      "type": [
       "string",
       "null"
      ]
     },
     "ownerDepartment": {
      "type": [
       "string",
       "null"
      ]
     },
     "createdAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "acknowledgedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     }
    }
   },
   "InvoiceDetail": {
    "description": "One invoice as the review screen reads it. Not the Invoice object of the list: the field names differ.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "displayId": {
      "type": "string",
      "description": "INV- and the id, padded to three digits."
     },
     "filename": {
      "type": [
       "string",
       "null"
      ]
     },
     "hasFile": {
      "type": "boolean",
      "description": "Whether the uploaded file is stored."
     },
     "fileContentType": {
      "type": [
       "string",
       "null"
      ]
     },
     "vendor": {
      "type": [
       "string",
       "null"
      ]
     },
     "amount": {
      "type": "number",
      "description": "In currency."
     },
     "currency": {
      "type": "string",
      "description": "USD when none was read."
     },
     "currencyDefaulted": {
      "type": "boolean",
      "description": "true when the currency was filled from the instance default."
     },
     "amountAmbiguous": {
      "type": "boolean",
      "description": "true when amountReviewNote is set."
     },
     "amountReviewNote": {
      "type": [
       "string",
       "null"
      ]
     },
     "dateReviewNote": {
      "type": [
       "string",
       "null"
      ]
     },
     "submitterNote": {
      "type": [
       "string",
       "null"
      ]
     },
     "baseCurrency": {
      "type": "string"
     },
     "amountBase": {
      "type": [
       "number",
       "null"
      ]
     },
     "dateIso": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "category": {
      "type": [
       "string",
       "null"
      ]
     },
     "status": {
      "type": [
       "string",
       "null"
      ]
     },
     "approvalStatus": {
      "type": "string",
      "description": "Draft when none is stored."
     },
     "extractionConfidence": {
      "type": [
       "number",
       "null"
      ]
     },
     "invoiceNumber": {
      "type": [
       "string",
       "null"
      ]
     },
     "taxAmount": {
      "type": [
       "number",
       "null"
      ]
     },
     "poNumber": {
      "type": [
       "string",
       "null"
      ]
     },
     "matchStatus": {
      "type": "string",
      "description": "unmatched when none is stored."
     },
     "lineItems": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "In line order, each { lineNo:integer, description:string|null, quantity:number, unitPrice:number, amount:number }."
     },
     "rawText": {
      "type": "string",
      "description": "The text read from the file; empty when there is none."
     }
    }
   },
   "PurchaseOrderDetail": {
    "description": "One purchase order with its lines and goods receipts.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "poNumber": {
      "type": "string"
     },
     "vendor": {
      "type": [
       "string",
       "null"
      ]
     },
     "currency": {
      "type": "string"
     },
     "totalAmount": {
      "type": "number",
      "description": "In currency."
     },
     "totalAmountBase": {
      "type": "number",
      "description": "In base currency."
     },
     "baseConverted": {
      "type": "boolean",
      "description": "false when no base amount was stored for the order."
     },
     "status": {
      "type": "string",
      "description": "open, partially_received, received, closed or cancelled."
     },
     "department": {
      "type": [
       "string",
       "null"
      ]
     },
     "costCenter": {
      "type": [
       "string",
       "null"
      ]
     },
     "project": {
      "type": [
       "string",
       "null"
      ]
     },
     "notes": {
      "type": [
       "string",
       "null"
      ]
     },
     "orderedDate": {
      "type": [
       "string",
       "null"
      ],
      "description": "Display text such as \"Oct 10, 2026\"."
     },
     "lines": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "In line order, each { id:integer, lineNo:integer, description:string|null, quantity:number, unitPrice:number, amount:number, qtyReceived:number, qtyInvoiced:number }."
     },
     "goodsReceipts": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Newest first, each { id:integer, grnNumber:string|null, receivedDate:string|null (display text) }."
     }
    }
   },
   "OwnedSystem": {
    "description": "A system linked to an owner and, optionally, a budget, with the cost its provider reports.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "label": {
      "type": "string"
     },
     "providerType": {
      "type": "string"
     },
     "kind": {
      "type": [
       "string",
       "null"
      ]
     },
     "systemRef": {
      "type": [
       "string",
       "null"
      ]
     },
     "budgetId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "metering": {
      "type": "string",
      "enum": [
       "live",
       "derived",
       "discovery"
      ]
     },
     "ownerEmail": {
      "type": [
       "string",
       "null"
      ]
     },
     "ownerDepartment": {
      "type": [
       "string",
       "null"
      ]
     },
     "cost": {
      "type": "number",
      "description": "0 when the provider could not be read."
     },
     "forecast": {
      "type": "number"
     },
     "isSynced": {
      "type": "boolean"
     },
     "lastSync": {
      "type": [
       "string",
       "null"
      ]
     }
    }
   },
   "BulkOutcomeItem": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "description": "The id as an integer, or the value you sent when it could not be read as one."
     },
     "ok": {
      "type": "boolean"
     },
     "error": {
      "type": "string",
      "description": "Present only when ok is false: the reason this item was not done."
     },
     "approvalStatus": {
      "type": "string",
      "description": "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."
     },
     "kind": {
      "type": "string",
      "enum": [
       "budget",
       "transfer"
      ],
      "description": "Present only in the budget-approvals bulk reply."
     }
    }
   },
   "BulkOutcome": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "action": {
      "type": "string",
      "description": "The action you sent. Present only in the invoice bulk reply and the budget-approvals bulk reply."
     },
     "ok": {
      "type": "integer",
      "description": "How many items were done."
     },
     "failed": {
      "type": "integer",
      "description": "How many were not."
     },
     "results": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/BulkOutcomeItem"
      },
      "description": "One entry per distinct valid id, plus one for each value that could not be read as an id."
     }
    }
   },
   "InvoiceImportSummary": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "imported": {
      "type": "integer",
      "description": "Rows stored as invoices."
     },
     "skipped": {
      "type": "integer",
      "description": "Rows not stored."
     },
     "errors": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "One message per skipped row; the first 50 are returned."
     },
     "currencyDefaulted": {
      "type": "integer",
      "description": "Rows that named no currency and were given the base currency."
     },
     "amountAmbiguous": {
      "type": "integer",
      "description": "Rows held for review because the amount could be read two ways."
     },
     "dateHeld": {
      "type": "integer",
      "description": "Rows held for review because the date could not be read with certainty."
     },
     "duplicatesSkipped": {
      "type": "integer",
      "description": "Rows skipped because the same vendor and invoice number are already on file or repeated in the file."
     },
     "duplicatesHeld": {
      "type": "integer",
      "description": "Rows stored but held for review because another invoice has the same vendor, amount and date."
     }
    }
   },
   "CurrencyRate": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "code": {
      "type": "string",
      "description": "Three letters."
     },
     "rateToBase": {
      "type": "number",
      "description": "How many units of the base currency one unit of code is worth."
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "description": "UTC."
     }
    }
   },
   "CurrencyTable": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "baseCurrency": {
      "type": "string"
     },
     "missingRates": {
      "type": "array",
      "description": "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."
     },
     "rates": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/CurrencyRate"
      },
      "description": "Every stored rate, ordered by code."
     }
    }
   },
   "VendorMergeResult": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "success": {
      "type": "boolean",
      "description": "Always true."
     },
     "keptVendorId": {
      "type": "integer"
     },
     "repointed": {
      "type": "integer",
      "description": "How many invoices moved to the kept vendor."
     },
     "skippedLocked": {
      "type": "array",
      "description": "The invoices left on the merged vendor because their accounting period is locked; each entry carries id, period and status."
     },
     "loserRetained": {
      "type": "boolean",
      "description": "true when the merged vendor was kept because invoices in a locked period still point at it."
     }
    }
   },
   "PeriodCloseResult": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "period": {
      "type": "string"
     },
     "status": {
      "type": "string",
      "description": "Always locked."
     },
     "journalsPosted": {
      "type": "integer",
      "description": "Invoices that were journalled by this close; one already journalled is not posted again."
     },
     "batch": {
      "type": "string",
      "description": "The period, a hyphen and the UTC time of the close to the second."
     },
     "forced": {
      "type": "boolean",
      "description": "true when the period was locked over open items."
     }
    }
   },
   "GLSyncRun": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "connector": {
      "type": "string"
     },
     "pushed": {
      "type": "integer",
      "description": "Journal lines marked as synced by this call."
     },
     "live": {
      "type": "boolean",
      "description": "false for the manual connector."
     },
     "note": {
      "type": "string"
     }
    }
   },
   "AlertSweepResult": {
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "evaluated": {
      "type": "integer",
      "description": "Current budgets looked at."
     },
     "fired": {
      "type": "integer",
      "description": "Alerts raised by this call."
     }
    }
   },
   "InvoiceReview": {
    "description": "Corrections to what was read from an uploaded invoice. Only the keys you send are changed.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "vendor": {
      "type": "string",
      "description": "Trimmed; an empty value changes nothing."
     },
     "category": {
      "type": "string",
      "description": "Trimmed; an empty value changes nothing."
     },
     "currency": {
      "type": "string",
      "description": "Upper-cased and cut to 3 letters."
     },
     "date": {
      "type": "string",
      "format": "date-time",
      "description": "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."
     },
     "amount": {
      "type": "number",
      "description": "A number, or text in a recognised number format. An entry that could be read two ways is refused with 400."
     },
     "po_number": {
      "type": [
       "string",
       "null"
      ],
      "description": "Trimmed, upper-cased and cut to 64 characters."
     },
     "note": {
      "type": [
       "string",
       "null"
      ],
      "description": "Trimmed and cut to 1000 characters."
     },
     "commit": {
      "type": "boolean",
      "description": "true releases an invoice held for review to Draft. Ignored unless the caller holds manage_finance."
     }
    }
   },
   "InvoiceAllocation": {
    "description": "Where an invoice is charged. Only the keys you send are changed. Refused with 409 once the invoice is Submitted, Approved or Paid.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "costCenter": {
      "type": [
       "string",
       "null"
      ]
     },
     "department": {
      "type": [
       "string",
       "null"
      ]
     },
     "project": {
      "type": [
       "string",
       "null"
      ]
     },
     "glCode": {
      "type": [
       "string",
       "null"
      ],
      "description": "Once a chart of accounts exists the code must be in it, or the answer is 400."
     },
     "currency": {
      "type": "string",
      "description": "Upper-cased and cut to 3 letters; the base amount is worked out again. 400 when no exchange rate is set for it."
     },
     "customFields": {
      "type": "object",
      "description": "Replaces the stored set. Keys that are not in the allocation custom-field schema, and empty values, are dropped."
     }
    }
   },
   "InvoiceAction": {
    "description": "The body of an approval step. Which keys are read depends on the action in the path; submit and clear_review read none.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reason": {
      "type": "string",
      "description": "Blank is refused with 400."
     },
     "budget_override": {
      "type": "boolean",
      "description": "approve only: approve although the invoice takes a budget over its limit. Needs approve_invoice."
     },
     "override_reason": {
      "type": "string",
      "description": "Blank is refused with 400."
     },
     "comment": {
      "type": "string",
      "description": "approve only: stored with the approval record."
     }
    }
   },
   "InvoiceBulkAction": {
    "description": "One action for many invoices. Each invoice is handled on its own and reported in results; an id sent twice is handled once.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "action": {
      "type": "string",
      "enum": [
       "submit",
       "approve",
       "reject",
       "pay",
       "clear_review",
       "allocate",
       "delete"
      ]
     },
     "ids": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "1 to 500 invoice ids."
     },
     "allocation": {
      "allOf": [
       {
        "$ref": "#/components/schemas/InvoiceAllocation"
       }
      ],
      "description": "Read for allocate only; without it nothing is changed."
     },
     "reason": {
      "type": "string",
      "description": "One reason shared by every invoice."
     },
     "budget_override": {
      "type": "boolean",
      "description": "approve only, as in InvoiceAction."
     },
     "override_reason": {
      "type": "string"
     },
     "comment": {
      "type": "string",
      "description": "approve only, as in InvoiceAction; one comment shared by every invoice."
     }
    },
    "required": [
     "action",
     "ids"
    ]
   },
   "VendorUpdate": {
    "description": "Only the keys you send are changed.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "canonicalName": {
      "type": "string",
      "description": "A name another vendor already has is refused with 409. Budgets scoped to the old name follow the new one."
     },
     "category": {
      "type": [
       "string",
       "null"
      ]
     },
     "owner": {
      "type": [
       "string",
       "null"
      ]
     },
     "ownerUserId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "contractRef": {
      "type": [
       "string",
       "null"
      ]
     },
     "contractRenewal": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "description": "ISO 8601; text that is not a date is stored as empty."
     },
     "notes": {
      "type": [
       "string",
       "null"
      ]
     },
     "aliases": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Replaces the alias list; each alias is normalised the way vendor names are matched."
     },
     "customFields": {
      "type": "object",
      "description": "Replaces the stored set. Keys that are not in the vendor custom-field schema, and empty values, are dropped."
     }
    }
   },
   "CustomFieldDefinition": {
    "description": "One entry of a custom-field schema (vendor or allocation).",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "key": {
      "type": "string",
      "description": "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."
     },
     "label": {
      "type": "string",
      "description": "Cut to 60 characters. Defaults to the key."
     },
     "type": {
      "type": "string",
      "enum": [
       "text",
       "select",
       "number",
       "date"
      ],
      "description": "Any other value is stored as text."
     },
     "options": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Read for select only: each cut to 60 characters, duplicates dropped, the first 50 kept."
     }
    },
    "required": [
     "key"
    ]
   },
   "CurrencyPolicyUpdate": {
    "description": "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.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "baseCurrency": {
      "type": "string",
      "description": "Three letters. Refused with 400 once invoices, purchase orders or budgets exist, or while the new currency has no rate."
     },
     "code": {
      "type": "string",
      "description": "Upper-cased and cut to 3 letters."
     },
     "rateToBase": {
      "type": "number",
      "description": "How many units of the base currency one unit of code is worth. Greater than 0, between 0.000001 and 1000000."
     }
    }
   },
   "FinanceSettingWrite": {
    "description": "One setting per call. The keys, their kinds and defaults are what GET /parser/finance/settings lists.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "key": {
      "x-catalogue-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",
      "description": "Any other key is refused with 400."
     },
     "value": {
      "description": "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."
     }
    },
    "required": [
     "key",
     "value"
    ]
   },
   "PurchaseOrderLineInput": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "description": {
      "type": "string"
     },
     "quantity": {
      "type": "number",
      "description": "Default 0."
     },
     "unit_price": {
      "type": "number",
      "description": "Default 0. The line amount is quantity times unit_price; an amount you send is not read."
     }
    }
   },
   "PurchaseOrderCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "po_number": {
      "type": "string",
      "description": "Trimmed and upper-cased. A number that already exists is refused with 409."
     },
     "vendor": {
      "type": "string",
      "description": "Trimmed and matched to a known vendor where one exists."
     },
     "currency": {
      "type": "string",
      "description": "Default USD. Upper-cased and cut to 3 letters; 400 when no exchange rate is set for it."
     },
     "lines": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/PurchaseOrderLineInput"
      },
      "description": "When present, the order total is the sum of the lines."
     },
     "total_amount": {
      "type": "number",
      "description": "Read only when lines is empty. Default 0."
     },
     "cost_center": {
      "type": "string"
     },
     "department": {
      "type": "string"
     },
     "project": {
      "type": "string"
     },
     "budget_override": {
      "type": "boolean",
      "description": "true creates the order although it takes a budget over its limit; without it that case answers 409."
     }
    },
    "required": [
     "po_number"
    ]
   },
   "GoodsReceiptLineInput": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "po_line_id": {
      "type": "integer",
      "description": "The purchase-order line received against."
     },
     "quantity_received": {
      "type": "number",
      "description": "Default 0."
     }
    }
   },
   "GoodsReceiptCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "po_id": {
      "type": "integer",
      "description": "404 when the purchase order does not exist."
     },
     "grn_number": {
      "type": "string"
     },
     "lines": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/GoodsReceiptLineInput"
      }
     }
    },
    "required": [
     "po_id"
    ]
   },
   "ApprovalRuleWrite": {
    "description": "Create and update take the same keys; an update changes only the keys you send. Amounts are in your base currency.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string",
      "description": "At most 80 characters. Default \"Rule\"."
     },
     "minAmount": {
      "type": "number",
      "description": "Default 0. Not negative; at most 999999999999.99."
     },
     "maxAmount": {
      "type": [
       "number",
       "null"
      ],
      "description": "null means no ceiling. Otherwise greater than minAmount and at most 999999999999.99."
     },
     "requiredApprovals": {
      "type": "integer",
      "description": "Default 1. A whole number from 1 to 10."
     },
     "isActive": {
      "type": "boolean",
      "description": "Default true."
     }
    }
   },
   "AdjustmentCreate": {
    "description": "An adjusting journal entry in the current open period: one debit and one credit of the same amount, in base currency.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reason": {
      "type": "string",
      "description": "Blank is refused with 400."
     },
     "debitGl": {
      "type": "string",
      "description": "A general-ledger code. Once a chart of accounts exists it must be in it."
     },
     "creditGl": {
      "type": "string",
      "description": "Must differ from debitGl."
     },
     "amount": {
      "type": "number",
      "description": "Greater than 0; rounded to 2 places."
     }
    },
    "required": [
     "reason",
     "debitGl",
     "creditGl",
     "amount"
    ]
   },
   "BudgetWrite": {
    "description": "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.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "period": {
      "type": "string",
      "description": "YYYY-MM or YYYY."
     },
     "scopeType": {
      "type": "string",
      "description": "Default global. A caller without a finance-wide budget permission may send only department, and it is set to their own department."
     },
     "scopeValue": {
      "type": [
       "string",
       "null"
      ],
      "description": "The department, cost centre or vendor the budget covers."
     },
     "amount": {
      "type": "number",
      "description": "Default 0. A negative number or text that is not a number is stored as 0."
     },
     "currency": {
      "type": [
       "string",
       "null"
      ],
      "description": "The currency amount is entered in; empty means the base currency. 400 when no exchange rate is set for it."
     },
     "name": {
      "type": [
       "string",
       "null"
      ]
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     },
     "budgetType": {
      "type": "string",
      "description": "Default opex."
     },
     "method": {
      "type": "string",
      "description": "Default top_down."
     },
     "startDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "description": "ISO 8601; text that is not a date is stored as empty."
     },
     "endDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "periodKind": {
      "type": [
       "string",
       "null"
      ]
     },
     "authoritativeSource": {
      "type": "string",
      "enum": [
       "invoice",
       "cloud",
       "both"
      ],
      "description": "Default invoice. An update ignores any other value."
     },
     "tags": {
      "type": "array",
      "description": "Read only when it is a list."
     },
     "glAccountId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "recurrence": {
      "type": [
       "string",
       "null"
      ]
     },
     "recurrenceInterval": {
      "type": [
       "string",
       "null"
      ]
     },
     "warnPct": {
      "type": [
       "number",
       "null"
      ],
      "description": "A fraction; brought inside 0 to 1."
     },
     "overPct": {
      "type": [
       "number",
       "null"
      ],
      "description": "A fraction; brought inside 0 to 1."
     }
    }
   },
   "BudgetLineWrite": {
    "description": "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.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "label": {
      "type": "string",
      "description": "Default \"Line\"."
     },
     "amount": {
      "type": "number",
      "description": "Rounded to 2 places. Not read when driverQty and unitCost are both set."
     },
     "driverKey": {
      "type": "string",
      "description": "With headcount or fte and no driverQty, the quantity is the head count of the budget's department where the directory holds one."
     },
     "driverQty": {
      "type": "number"
     },
     "unitCost": {
      "type": "number"
     },
     "glAccountId": {
      "type": "integer"
     },
     "category": {
      "type": "string"
     },
     "costCenter": {
      "type": "string"
     },
     "justification": {
      "type": "string"
     },
     "sortOrder": {
      "type": "integer",
      "description": "Default 0."
     }
    }
   },
   "BudgetTransferCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "fromBudgetId": {
      "type": "integer"
     },
     "toBudgetId": {
      "type": "integer",
      "description": "Must differ from fromBudgetId."
     },
     "amount": {
      "type": "number",
      "description": "Greater than 0."
     },
     "currency": {
      "type": "string",
      "description": "The currency amount is entered in; empty means the base currency. 400 when an exchange rate is missing for any side."
     },
     "reason": {
      "type": "string"
     }
    },
    "required": [
     "fromBudgetId",
     "toBudgetId",
     "amount"
    ]
   },
   "BudgetApprovalsBulk": {
    "description": "Approve or reject budgets and transfers in one call. Each item is decided on its own and reported in results.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "action": {
      "type": "string",
      "enum": [
       "approve",
       "reject"
      ]
     },
     "budgetIds": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "Together with transferIds: at least 1 and at most 500 items."
     },
     "transferIds": {
      "type": "array",
      "items": {
       "type": "integer"
      }
     },
     "reason": {
      "type": "string",
      "description": "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."
     }
    },
    "required": [
     "action"
    ]
   },
   "SpendRequestCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "budgetId": {
      "type": "integer",
      "description": "A budget in your scope: 404 when it does not exist, 403 when it is outside your scope."
     },
     "amount": {
      "type": "number",
      "description": "Greater than 0."
     },
     "currency": {
      "type": "string",
      "description": "Empty means the base currency. 400 when no exchange rate is set for it."
     },
     "description": {
      "type": "string"
     },
     "vendor": {
      "type": "string"
     }
    },
    "required": [
     "budgetId",
     "amount"
    ]
   },
   "OwnedSystemClaim": {
    "description": "Attaches a discovered system to an owner and, optionally, a budget.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "providerType": {
      "type": "string",
      "description": "Upper-cased. AWS, AZURE and AZURE_AD are metered live; GOOGLE_WORKSPACE and GITHUB are derived; any other value is recorded as discovery."
     },
     "externalProviderId": {
      "type": "integer",
      "description": "A provider id from GET /parser/finance/systems/providers."
     },
     "systemRef": {
      "type": "string"
     },
     "label": {
      "type": "string",
      "description": "Defaults to systemRef, then providerType, then \"System\"."
     },
     "kind": {
      "type": "string"
     },
     "budgetId": {
      "type": "integer",
      "description": "A budget you can edit: 404 when it does not exist, 403 when it is not yours to edit."
     },
     "ownerEmail": {
      "type": "string",
      "description": "Defaults to you. Ignored for a caller without a finance-wide budget permission, who always becomes the owner."
     }
    }
   },
   "DocumentStatusWrite": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "status": {
      "type": "string",
      "description": "Stored as sent. paid and approved (in any letter case) are refused with 403: those are reached through the approval steps."
     }
    },
    "required": [
     "status"
    ]
   },
   "FinanceQuestion": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "question": {
      "type": "string",
      "description": "Trimmed; blank is refused with 400."
     }
    },
    "required": [
     "question"
    ]
   },
   "AppBudgetWrite": {
    "description": "The yearly budget of one app (vendor), for the current calendar year.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "amount": {
      "type": "number",
      "description": "Greater than 0 sets the budget. Zero, a negative number, a missing key or text that is not a number clears it."
     }
    }
   },
   "VendorBulkPatch": {
    "description": "At least one of the two keys. Any other key is not read.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "category": {
      "type": "string"
     },
     "owner": {
      "type": "string"
     }
    }
   },
   "VendorBulkUpdate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "ids": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "1 to 500 vendor ids; the limit of 500 is counted before duplicates are removed. A valid id sent twice is handled once."
     },
     "patch": {
      "$ref": "#/components/schemas/VendorBulkPatch"
     }
    },
    "required": [
     "ids",
     "patch"
    ]
   },
   "VendorMerge": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "keepId": {
      "type": "integer",
      "description": "The vendor that stays."
     },
     "mergeId": {
      "type": "integer",
      "description": "The vendor whose invoices and aliases move to keepId."
     }
    },
    "required": [
     "keepId",
     "mergeId"
    ]
   },
   "DigestSend": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "recipients": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Email addresses. Without it the configured digest recipients are used."
     }
    }
   },
   "PurchaseOrderStatusWrite": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "status": {
      "type": "string",
      "enum": [
       "closed",
       "cancelled",
       "open"
      ],
      "description": "Default closed. Any other value is refused with 400."
     }
    }
   },
   "PurchaseOrderBulkStatus": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "ids": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "1 to 500 purchase-order ids; the limit of 500 is counted before duplicates are removed. A valid id sent twice is handled once."
     },
     "status": {
      "type": "string",
      "enum": [
       "closed",
       "cancelled",
       "open"
      ],
      "description": "Default closed."
     }
    },
    "required": [
     "ids"
    ]
   },
   "MatchExceptionWaive": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reason": {
      "type": "string",
      "description": "Trimmed; blank is refused with 400."
     }
    },
    "required": [
     "reason"
    ]
   },
   "MatchExceptionBulkWaive": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "ids": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "1 to 500 match-exception ids; the limit of 500 is counted before duplicates are removed. A valid id sent twice is handled once."
     },
     "reason": {
      "type": "string",
      "description": "One reason shared by every exception."
     }
    },
    "required": [
     "ids",
     "reason"
    ]
   },
   "GLAccountCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "code": {
      "type": "string",
      "description": "Trimmed. A code that already exists is refused with 409."
     },
     "name": {
      "type": "string",
      "description": "Defaults to the code."
     },
     "type": {
      "type": "string",
      "description": "The account type, stored as sent."
     }
    },
    "required": [
     "code"
    ]
   },
   "PeriodClose": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "force": {
      "type": "boolean",
      "description": "true locks the period although it still has open items."
     },
     "reason": {
      "type": "string",
      "description": "Trimmed; blank with force is refused with 400."
     }
    }
   },
   "GLConnectorWrite": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "connector": {
      "type": "string",
      "enum": [
       "manual",
       "quickbooks",
       "netsuite",
       "xero"
      ],
      "description": "Trimmed and lower-cased. Any other value is refused with 400."
     }
    },
    "required": [
     "connector"
    ]
   },
   "BudgetReject": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reason": {
      "type": "string",
      "description": "Trimmed; blank is refused with 400."
     }
    },
    "required": [
     "reason"
    ]
   },
   "BudgetLock": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "locked": {
      "type": "boolean",
      "description": "Default true. false unlocks, which needs manage_finance_policy."
     }
    }
   },
   "BudgetFreeze": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "frozen": {
      "type": "boolean",
      "description": "Default true. false lifts the freeze."
     },
     "reason": {
      "type": "string",
      "description": "Stored with a freeze; not kept when the freeze is lifted."
     }
    }
   },
   "BudgetCopy": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "period": {
      "type": "string",
      "description": "The period of the copy: YYYY-MM or YYYY."
     }
    },
    "required": [
     "period"
    ]
   },
   "BudgetCommentCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "comment": {
      "type": "string",
      "description": "Blank is refused with 400."
     }
    },
    "required": [
     "comment"
    ]
   },
   "TransferReject": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reason": {
      "type": "string",
      "description": "Trimmed; stored as the rejection reason."
     }
    }
   },
   "SpendRequestDecision": {
    "description": "Read for the reject and cancel actions only; approve and fulfill read no body.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reason": {
      "type": "string"
     }
    }
   },
   "AgentPackage": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "version": {
      "type": [
       "string",
       "null"
      ],
      "description": "Null for an installer uploaded without a version."
     },
     "filename": {
      "type": "string"
     },
     "sha256": {
      "type": "string",
      "description": "SHA-256 of the hosted file, 64 hex characters."
     },
     "sizeBytes": {
      "type": "integer"
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true,
      "description": "When it was uploaded."
     }
    }
   },
   "AgentPackageUpload": {
    "description": "The reply to an installer upload.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "platform": {
      "type": "string",
      "description": "macos | linux | windows"
     },
     "version": {
      "type": [
       "string",
       "null"
      ]
     },
     "filename": {
      "type": "string"
     },
     "sha256": {
      "type": "string",
      "description": "SHA-256 of the stored file, 64 hex characters."
     },
     "sizeBytes": {
      "type": "integer"
     },
     "sig": {
      "type": [
       "string",
       "null"
      ],
      "description": "The vendor release signature carried by a signed bundle."
     },
     "kid": {
      "type": [
       "string",
       "null"
      ],
      "description": "The id of the key that signature was verified with."
     },
     "signed": {
      "type": "boolean",
      "description": "True when the upload was a signed bundle."
     }
    }
   },
   "DeviceSetupState": {
    "description": "Which platforms are in use. Answered by the setup-state read.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "platforms": {
      "type": "object",
      "description": "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."
     },
     "anyActive": {
      "type": "boolean",
      "description": "True when any platform is active."
     }
    }
   },
   "EnrollmentToken": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "label": {
      "type": [
       "string",
       "null"
      ]
     },
     "expiresAt": {
      "type": "string",
      "format": "date-time"
     },
     "maxUses": {
      "type": "integer",
      "description": "0 means unlimited."
     },
     "useCount": {
      "type": "integer",
      "readOnly": true
     },
     "usedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "revokedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "status": {
      "type": "string",
      "readOnly": true,
      "description": "revoked | expired | used (every use spent) | active. Checked in that order."
     }
    }
   },
   "EnrollmentTokenCreate": {
    "description": "Issues an enrolment token. The reply is { id, token, label, expiresAt, maxUses }; token is the value, shown only in that reply.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "label": {
      "type": "string",
      "description": "Cut to 120 characters."
     },
     "ttlHours": {
      "type": "integer",
      "description": "Default 24. Held to 1 to 720."
     },
     "maxUses": {
      "type": "integer",
      "description": "Default 1. Held to 1 to 500."
     }
    }
   },
   "DeviceCommandType": {
    "description": "One entry of the command catalogue: { types: DeviceCommandType[] }. A fixed list.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "type": {
      "type": "string",
      "description": "lock | uninstall | restart | update_os | rename"
     },
     "label": {
      "type": "string"
     },
     "platforms": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "macos | linux | windows"
     },
     "destructive": {
      "type": "boolean",
      "description": "True for uninstall only."
     },
     "args": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "The arguments the command takes. Empty except for rename: one { name: \"name\", label, type: \"string\", required: true, maxLength: 63, maxLengthByPlatform: { windows: 15 }, pattern }."
     }
    }
   },
   "DeviceCommandCreate": {
    "description": "Queues one command for one device. The batch call takes the same fields plus ids (1 to 500 device ids).",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "type": {
      "type": "string",
      "description": "lock | uninstall | restart | update_os | rename"
     },
     "args": {
      "type": "object",
      "description": "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."
     },
     "reason": {
      "type": "string",
      "description": "Required for uninstall."
     }
    },
    "required": [
     "type"
    ]
   },
   "DeviceCommand": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "deviceId": {
      "type": "integer",
      "readOnly": true
     },
     "type": {
      "type": "string",
      "description": "lock | uninstall | restart | update_os | rename"
     },
     "args": {
      "type": [
       "object",
       "null"
      ]
     },
     "status": {
      "type": "string",
      "readOnly": true,
      "description": "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."
     },
     "requestedBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "reason": {
      "type": [
       "string",
       "null"
      ]
     },
     "batchId": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true,
      "description": "Shared by the commands of one batch call."
     },
     "result": {
      "type": [
       "object",
       "null"
      ],
      "readOnly": true,
      "description": "{ status, detail } as the agent reported it, or { detail } for a cancelled or expired command."
     },
     "deliveredAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "completedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "createdAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "DeviceCommandResult": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "ok": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { deviceId, commandId, noop }: noop is present, as true, only when nothing new was queued."
     },
     "failed": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Batch call: each { deviceId, error } and, where one applies, pendingCommand. Single-device call: each { deviceId, error, status } and, where one applies, code and pendingCommand."
     },
     "batchId": {
      "type": [
       "string",
       "null"
      ],
      "description": "Batch call only. Null when the call named a single device."
     }
    }
   },
   "DeviceGroup": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "name": {
      "type": "string"
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     },
     "kind": {
      "type": "string",
      "description": "static | smart"
     },
     "platform": {
      "type": [
       "string",
       "null"
      ],
      "description": "macos | linux | windows. Null for a group that is not bound to one platform."
     },
     "criteria": {
      "type": [
       "object",
       "null"
      ],
      "description": "The rules of a smart group."
     },
     "createdBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "memberCount": {
      "type": "integer",
      "readOnly": true,
      "description": "Every member."
     },
     "enforceableCount": {
      "type": "integer",
      "readOnly": true,
      "description": "The members that profiles and rollouts actually target."
     }
    }
   },
   "DeviceLite": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "hostname": {
      "type": [
       "string",
       "null"
      ]
     },
     "name": {
      "type": [
       "string",
       "null"
      ]
     },
     "serialNumber": {
      "type": [
       "string",
       "null"
      ]
     },
     "platform": {
      "type": [
       "string",
       "null"
      ]
     },
     "ownerName": {
      "type": [
       "string",
       "null"
      ]
     },
     "loggedInUser": {
      "type": [
       "string",
       "null"
      ]
     }
    }
   },
   "DeviceActivityEntry": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "action": {
      "type": "string",
      "description": "The audit action name."
     },
     "level": {
      "type": "string"
     },
     "userEmail": {
      "type": [
       "string",
       "null"
      ]
     },
     "createdAt": {
      "type": "string",
      "format": "date-time"
     },
     "details": {
      "type": [
       "string",
       "null"
      ]
     }
    }
   },
   "ProfileRollout": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "profileId": {
      "type": "integer",
      "readOnly": true
     },
     "fromVersion": {
      "type": [
       "integer",
       "null"
      ],
      "readOnly": true,
      "description": "The profile version in force before the rollout."
     },
     "toVersion": {
      "type": "integer",
      "readOnly": true,
      "description": "The version being rolled out."
     },
     "rings": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "2 to 5 rings, each { type: \"groups\", groupIds, label }, { type: \"percent\", percent, label } or, for the last ring, { type: \"all\", label }."
     },
     "currentRing": {
      "type": "integer",
      "readOnly": true,
      "description": "The index of the ring now receiving the version, from 0."
     },
     "state": {
      "type": "string",
      "readOnly": true,
      "description": "running | paused | complete | rolled_back | canceled"
     },
     "autoPromote": {
      "type": "boolean",
      "description": "Default true."
     },
     "promoteAfterHours": {
      "type": "integer",
      "description": "Default 24. 1 to 720."
     },
     "ringStartedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "startedBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "endedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "endedBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "holdReason": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     }
    }
   },
   "AgentRollout": {
    "description": "One agent version rollout. The rollout read answers { platform, rollouts: AgentRollout[] }: every rollout of the platform in one reply, newest first.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "platform": {
      "type": "string",
      "description": "macos | linux | windows"
     },
     "version": {
      "type": "string",
      "description": "The agent version targeted."
     },
     "enabled": {
      "type": "boolean",
      "description": "False while the rollout is on hold."
     },
     "label": {
      "type": [
       "string",
       "null"
      ]
     },
     "scope": {
      "type": "object",
      "description": "{ type: \"all\" } or a scope naming groups or devices."
     },
     "scopeLabel": {
      "type": "string",
      "readOnly": true
     },
     "deviceCount": {
      "type": "integer",
      "readOnly": true,
      "description": "Devices in scope."
     },
     "onTarget": {
      "type": "integer",
      "readOnly": true,
      "description": "Devices in scope already on that version or a newer one."
     },
     "createdBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "MdmStatus": {
    "description": "Whether Apple MDM is set up and reachable. { configured: false } alone when it is not set up.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "configured": {
      "type": "boolean"
     },
     "connected": {
      "type": "boolean",
      "description": "Whether the MDM control plane answered."
     },
     "tenant": {
      "type": [
       "string",
       "null"
      ]
     },
     "deviceCount": {
      "type": [
       "integer",
       "null"
      ],
      "description": "Null when the control plane did not answer."
     },
     "enrollmentUrl": {
      "type": [
       "string",
       "null"
      ]
     },
     "enrollmentReady": {
      "type": "boolean"
     },
     "hasCert": {
      "type": "boolean",
      "description": "Whether a push certificate is installed."
     },
     "certNotAfter": {
      "type": [
       "string",
       "null"
      ],
      "description": "When the push certificate expires."
     }
    }
   },
   "SoftwarePage": {
    "description": "One page of the software inventory. Paged by ?page and ?pageSize (default 50, 1 to 500).",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "items": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { name, publisher, deviceCount, platforms, versions: [{ version, count }] }."
     },
     "total": {
      "type": "integer",
      "description": "Every title matching the filters."
     },
     "page": {
      "type": "integer"
     },
     "pageSize": {
      "type": "integer"
     }
    }
   },
   "DeviceCreate": {
    "description": "Adds a device record by hand. Only these keys are read; any other key is ignored.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string"
     },
     "type": {
      "type": "string"
     },
     "serialNumber": {
      "type": "string",
      "description": "Unique across devices."
     },
     "status": {
      "type": "string",
      "description": "Default available."
     },
     "manufacturer": {
      "type": "string"
     },
     "userId": {
      "type": "integer",
      "description": "The owner, a user id."
     },
     "assetTag": {
      "type": "string"
     },
     "department": {
      "type": "string"
     },
     "location": {
      "type": "string"
     },
     "costCenter": {
      "type": "string"
     }
    },
    "required": [
     "name"
    ]
   },
   "DeviceAlertSettingsUpdate": {
    "description": "Only the keys you send are changed. A value of the wrong type or out of range answers 400 and nothing is saved.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "pendingEnrollment": {
      "type": "boolean"
     },
     "noncompliant": {
      "type": "boolean"
     },
     "encryptionOff": {
      "type": "boolean"
     },
     "silentDays": {
      "type": [
       "integer",
       "null"
      ],
      "description": "1 to 365. Null turns the alert off."
     },
     "enrollTokenDays": {
      "type": [
       "integer",
       "null"
      ],
      "description": "1 to 90. Null turns the alert off."
     },
     "pushCertDays": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "At most 6 whole numbers, each 1 to 365. Stored without repeats, largest first."
     },
     "recipients": {
      "type": "string",
      "description": "Only the value manage_devices is accepted; anything else answers 400."
     }
    }
   },
   "DeviceStaleSettingUpdate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "staleDays": {
      "type": "integer",
      "description": "A whole number from 1 to 365. deviceStaleDays is read when staleDays is not sent."
     }
    },
    "required": [
     "staleDays"
    ]
   },
   "DeviceCustomFieldsWrite": {
    "description": "Replaces the whole list of custom device fields.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "fields": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "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."
     }
    }
   },
   "InstallInvitesSend": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "userIds": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "The users to email. At least one whole number."
     }
    },
    "required": [
     "userIds"
    ]
   },
   "ReservationLink": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reservationId": {
      "type": "integer",
      "description": "The id of the reservation record to link the device to."
     }
    },
    "required": [
     "reservationId"
    ]
   },
   "EnrollmentReject": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reason": {
      "type": "string",
      "description": "At most 234 characters after trimming, or the answer is 400."
     }
    }
   },
   "DeviceIdsRequest": {
    "description": "The body of the batch approve and unretire calls.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "ids": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "1 to 500 device ids. Repeated ids are counted once; an entry that is not a positive whole number is dropped."
     }
    },
    "required": [
     "ids"
    ]
   },
   "DevicePolicyUpdate": {
    "description": "Only the keys you send are changed. The hardening and software-update switches named on the 409 are read by this route too.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "platform": {
      "type": "string",
      "enum": [
       "macos",
       "linux",
       "windows"
      ],
      "description": "Default macos."
     },
     "checkInIntervalMinutes": {
      "type": "integer",
      "description": "Held to 5 to 1440."
     },
     "allowUserQuit": {
      "type": "boolean"
     },
     "minOsVersion": {
      "type": [
       "string",
       "null"
      ],
      "description": "A dotted number such as 14.5, 24.04 or 10.0.22631. An empty string also clears it."
     },
     "minAgentVersion": {
      "type": [
       "string",
       "null"
      ],
      "description": "A version such as 1.1.0, not above the adopted agent version. An empty string also clears it."
     }
    }
   },
   "DeviceAssign": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "userId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The owner, a user id. Null, or leaving the key out, unassigns the owner."
     },
     "assetTag": {
      "type": "string",
      "description": "Cut to 255 characters. An empty string clears it; a key left out keeps its value. The same for department, location and costCenter."
     },
     "department": {
      "type": "string"
     },
     "location": {
      "type": "string"
     },
     "costCenter": {
      "type": "string"
     },
     "customFields": {
      "type": "object",
      "description": "Values keyed by custom field key. Keys that are not in the custom field list are dropped."
     }
    }
   },
   "DeviceBatchAssign": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "ids": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "1 to 500 device ids. Repeated ids are counted once; an entry that is not a positive whole number is dropped."
     },
     "userId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "A user id, or null to unassign. Leaving the key out answers 400."
     }
    },
    "required": [
     "ids",
     "userId"
    ]
   },
   "DeviceBatchCommandCreate": {
    "description": "Queues one command for many devices.",
    "x-kind": "request body",
    "allOf": [
     {
      "$ref": "#/components/schemas/DeviceCommandCreate"
     },
     {
      "type": "object",
      "properties": {
       "ids": {
        "type": "array",
        "items": {
         "type": "integer"
        },
        "description": "1 to 500 device ids. Repeated ids are counted once; an entry that is not a positive whole number is dropped."
       }
      },
      "required": [
       "ids"
      ]
     }
    ]
   },
   "DeviceRetire": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "ids": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "1 to 500 device ids. Repeated ids are counted once; an entry that is not a positive whole number is dropped."
     },
     "reason": {
      "type": "string",
      "description": "3 to 255 characters after trimming."
     }
    },
    "required": [
     "ids",
     "reason"
    ]
   },
   "DeviceGroupCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string"
     },
     "description": {
      "type": "string"
     },
     "kind": {
      "type": "string",
      "enum": [
       "static",
       "smart"
      ],
      "description": "Default static: any value other than smart is read as static."
     },
     "platform": {
      "type": "string",
      "enum": [
       "macos",
       "linux",
       "windows"
      ],
      "description": "Binds the group to one platform. Any other value leaves it unbound."
     },
     "criteria": {
      "type": "object",
      "description": "Smart groups only, where it is required and checked."
     },
     "deviceIds": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "Static groups only: the first members. In a platform-bound group, devices of another platform are left out."
     }
    },
    "required": [
     "name"
    ]
   },
   "DeviceGroupUpdate": {
    "description": "Only the keys you send are changed.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string",
      "description": "An empty name is ignored."
     },
     "description": {
      "type": "string",
      "description": "An empty string clears it."
     },
     "criteria": {
      "type": "object",
      "description": "Smart groups only; ignored on a static group."
     }
    }
   },
   "DeviceGroupPreview": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "criteria": {
      "type": "object",
      "description": "The rules to try, checked as for a smart group."
     },
     "platform": {
      "type": "string",
      "enum": [
       "macos",
       "linux",
       "windows"
      ]
     },
     "kind": {
      "type": "string",
      "description": "When sent it must be smart."
     }
    },
    "required": [
     "criteria"
    ]
   },
   "DeviceGroupMembers": {
    "description": "The body of the add-members and bulk-remove calls.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "deviceIds": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "At least one device id."
     }
    },
    "required": [
     "deviceIds"
    ]
   },
   "DeviceProfileCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string"
     },
     "platform": {
      "type": "string",
      "enum": [
       "macos",
       "linux",
       "windows"
      ],
      "description": "Cannot be changed later."
     },
     "payloads": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { key, settings }. A key the platform does not support, or one that is not available, answers 400."
     },
     "scope": {
      "type": "object",
      "description": "{ type: all | groups | devices, groupIds, deviceIds, excludeGroupIds, excludeDeviceIds }. Default type all."
     },
     "description": {
      "type": "string"
     },
     "enabled": {
      "type": "boolean",
      "description": "Default false."
     }
    },
    "required": [
     "name",
     "platform"
    ]
   },
   "DeviceProfileUpdate": {
    "description": "Only the keys you send are changed. The profile version goes up by one when something changed.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string"
     },
     "description": {
      "type": "string"
     },
     "payloads": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "As on create."
     },
     "scope": {
      "type": "object",
      "description": "As on create."
     },
     "enabled": {
      "type": "boolean"
     },
     "platform": {
      "type": "string",
      "enum": [
       "macos",
       "linux",
       "windows"
      ],
      "description": "A profile that has a platform keeps it: a different value answers 400."
     },
     "rollout": {
      "allOf": [
       {
        "$ref": "#/components/schemas/ProfileRolloutStart"
       }
      ],
      "description": "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."
     }
    }
   },
   "ProfileIdsRequest": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "ids": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "1 to 100 profile ids. A repeated id is deployed once."
     }
    },
    "required": [
     "ids"
    ]
   },
   "ProfileRolloutStart": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "rings": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "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\" }."
     },
     "autoPromote": {
      "type": "boolean",
      "description": "Default true."
     },
     "promoteAfterHours": {
      "type": "integer",
      "description": "1 to 720. Default 24."
     }
    },
    "required": [
     "rings"
    ]
   },
   "ProfileRolloutSettings": {
    "description": "Either key or both.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "autoPromote": {
      "type": "boolean"
     },
     "promoteAfterHours": {
      "type": "integer",
      "description": "1 to 720."
     }
    }
   },
   "MdmEnrollCodeCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reusable": {
      "type": "boolean",
      "description": "True for a reusable code. Anything else gives a single-use code."
     }
    }
   },
   "MdmPushCertUpload": {
    "description": "Send certPem (with keyPem), or p12 (with passphrase). 413 when the certificate and key together are over 200,000 characters.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "certPem": {
      "type": "string",
      "description": "The Apple push certificate in PEM form. Required unless p12 is sent."
     },
     "keyPem": {
      "type": "string",
      "description": "Its private key in PEM form."
     },
     "p12": {
      "type": "string",
      "description": "A .p12 file holding both. When it is sent, certPem and keyPem are not read. 400 when it cannot be opened."
     },
     "passphrase": {
      "type": "string",
      "description": "The export password of the .p12."
     }
    }
   },
   "MdmLockRequest": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "message": {
      "type": "string",
      "description": "Passed to the MDM control plane with the lock."
     }
    }
   },
   "DestructiveReason": {
    "description": "The body of the remote wipe, recovery-key reveal and recovery-key rotation calls.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reason": {
      "type": "string",
      "description": "At least 3 characters after trimming. The wipe and rotation calls cut it to 500 characters."
     }
    },
    "required": [
     "reason"
    ]
   },
   "AgentRolloutHold": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "enabled": {
      "type": "boolean",
      "description": "True resumes the rollout. False, or leaving the key out, holds it."
     }
    }
   },
   "EnrollmentTokenIssued": {
    "description": "The reply to issuing an enrolment token. The only reply that carries the token value.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "token": {
      "type": "string",
      "readOnly": true,
      "description": "The token value. Only its hash is stored."
     },
     "label": {
      "type": [
       "string",
       "null"
      ]
     },
     "expiresAt": {
      "type": "string",
      "format": "date-time"
     },
     "maxUses": {
      "type": "integer"
     }
    }
   },
   "DeviceBatchOutcome": {
    "description": "The reply to the batch approve and batch assign calls. Each device is tried on its own, so read failed even on a 200.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "ok": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Batch approve: each { deviceId, enrollmentApproved: true }. Batch assign: each { deviceId, userId }, userId null when unassigned."
     },
     "failed": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { deviceId, error }."
     }
    }
   },
   "DeviceLifecycleOutcome": {
    "description": "The reply to the retire and unretire calls. Each device is tried on its own, so read failed even on a 200.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "ok": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "The ids done. A device already in the state asked for is listed here and nothing about it is changed."
     },
     "failed": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { id, error }; \"Device not found\" for an unknown id."
     }
    }
   },
   "AgentEnrollRequest": {
    "description": "The first call an agent makes. A device is one record per serial number.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "enrollmentToken": {
      "type": "string"
     },
     "serialNumber": {
      "type": "string",
      "description": "At most 255 characters. Spaces at either end are removed."
     },
     "publicKey": {
      "type": "string",
      "description": "The device's P-256 public key. A key on another curve is refused."
     },
     "hostname": {
      "type": "string",
      "description": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
     },
     "osVersion": {
      "type": "string",
      "description": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
     },
     "osBuild": {
      "type": "string",
      "description": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
     },
     "architecture": {
      "type": "string",
      "description": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
     },
     "chip": {
      "type": "string",
      "description": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
     },
     "modelName": {
      "type": "string",
      "description": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
     },
     "agentVersion": {
      "type": "string",
      "description": "The version of the agent that is running. The check-in offers an update only when a newer version is the target."
     },
     "loggedInUser": {
      "type": "string",
      "description": "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."
     },
     "platform": {
      "type": "string",
      "description": "macos, linux or windows, in any letter case. At check-in any other value gets the macOS policy."
     },
     "cpuCores": {
      "type": "integer",
      "description": "A whole number, zero or more, or the same written as digits. Any other value is ignored."
     },
     "memoryBytes": {
      "type": "integer",
      "description": "A whole number, zero or more, or the same written as digits. Any other value is ignored."
     },
     "diskTotalBytes": {
      "type": "integer",
      "description": "A whole number, zero or more, or the same written as digits. Any other value is ignored."
     },
     "diskFreeBytes": {
      "type": "integer",
      "description": "A whole number, zero or more, or the same written as digits. Any other value is ignored."
     },
     "osUpdateAvailable": {
      "type": "boolean",
      "description": "Also read: the text true or false, 1 or 0."
     },
     "inventory": {
      "type": "object",
      "description": "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."
     }
    },
    "required": [
     "enrollmentToken",
     "serialNumber",
     "publicKey"
    ]
   },
   "AgentEnrollReply": {
    "description": "The reply to an enrolment.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "deviceId": {
      "type": "integer",
      "description": "The id to send in the x-zx-device-id header of every signed call."
     }
    }
   },
   "AgentHeartbeatRequest": {
    "description": "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.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "hostname": {
      "type": "string",
      "description": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
     },
     "osVersion": {
      "type": "string",
      "description": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
     },
     "osBuild": {
      "type": "string",
      "description": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
     },
     "architecture": {
      "type": "string",
      "description": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
     },
     "chip": {
      "type": "string",
      "description": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
     },
     "modelName": {
      "type": "string",
      "description": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
     },
     "agentVersion": {
      "type": "string",
      "description": "The version of the agent that is running. The check-in offers an update only when a newer version is the target."
     },
     "loggedInUser": {
      "type": "string",
      "description": "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."
     },
     "platform": {
      "type": "string",
      "description": "macos, linux or windows, in any letter case. At check-in any other value gets the macOS policy."
     },
     "cpuCores": {
      "type": "integer",
      "description": "A whole number, zero or more, or the same written as digits. Any other value is ignored."
     },
     "memoryBytes": {
      "type": "integer",
      "description": "A whole number, zero or more, or the same written as digits. Any other value is ignored."
     },
     "diskTotalBytes": {
      "type": "integer",
      "description": "A whole number, zero or more, or the same written as digits. Any other value is ignored."
     },
     "diskFreeBytes": {
      "type": "integer",
      "description": "A whole number, zero or more, or the same written as digits. Any other value is ignored."
     },
     "osUpdateAvailable": {
      "type": "boolean",
      "description": "Also read: the text true or false, 1 or 0."
     },
     "inventory": {
      "type": "object",
      "description": "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."
     },
     "capabilities": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "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."
     },
     "commandResults": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/AgentCommandResult"
      },
      "description": "The outcome of commands delivered on an earlier check-in. The first 50 entries are read."
     },
     "enforcement": {
      "allOf": [
       {
        "$ref": "#/components/schemas/AgentEnforcementReport"
       }
      ],
      "description": "Which policy the agent applied."
     }
    }
   },
   "AgentCommandResult": {
    "description": "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.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "description": "The id of the command, as the check-in delivered it."
     },
     "status": {
      "type": "string",
      "enum": [
       "succeeded",
       "failed",
       "unsupported"
      ]
     },
     "detail": {
      "type": "string",
      "description": "Cut to 500 characters."
     }
    },
    "required": [
     "id",
     "status"
    ]
   },
   "AgentEnforcementReport": {
    "description": "The enforcement key of a check-in.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "policyHash": {
      "type": "string",
      "description": "The policyHash of the policy the agent applied, from an earlier reply."
     },
     "applied": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "The policy fields applied. At most 200 are read, each cut to 64 characters."
     },
     "failed": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "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."
     }
    }
   },
   "AgentHeartbeatReply": {
    "description": "The reply to a check-in: the policy to apply, the commands to run and the update to install. It can carry further keys.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "ok": {
      "type": "boolean",
      "description": "Always true."
     },
     "deviceId": {
      "type": "integer"
     },
     "command": {
      "type": [
       "string",
       "null"
      ],
      "description": "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."
     },
     "trustBundle": {
      "type": [
       "object",
       "null"
      ],
      "description": "The current list of release-signing keys, or null when the instance holds none."
     },
     "policy": {
      "$ref": "#/components/schemas/AgentPolicy"
     },
     "update": {
      "anyOf": [
       {
        "$ref": "#/components/schemas/AgentUpdate"
       },
       {
        "type": "null"
       }
      ],
      "description": "Null unless a version newer than the agentVersion sent is the target for this device."
     },
     "policyHash": {
      "type": "string",
      "description": "SHA-256 of the policy object, 64 hex characters. Send it back in enforcement.policyHash."
     },
     "commands": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/AgentCommand"
      },
      "description": "Only for an agent that declares commands_v2. At most 20 per check-in, oldest first. Each is delivered once."
     },
     "commandResultsAccepted": {
      "type": "boolean",
      "description": "Only for an agent that declares commands_v2, and only when the request carried commandResults. False means they were not stored: send them again."
     }
    }
   },
   "AgentCommand": {
    "description": "One entry of commands in the check-in reply.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "description": "Send it back as the id of the AgentCommandResult."
     },
     "type": {
      "type": "string",
      "description": "The command, for example lock, uninstall, restart, update_os or rename."
     },
     "args": {
      "type": [
       "object",
       "null"
      ],
      "description": "The arguments of the command, for example { name } for rename."
     }
    }
   },
   "AgentUpdate": {
    "description": "The agent version to install, in the update key of the check-in reply.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "version": {
      "type": "string"
     },
     "url": {
      "type": "string",
      "description": "Where to download exactly this version."
     },
     "sha256": {
      "type": "string",
      "description": "SHA-256 of the file, 64 hex characters."
     },
     "sig": {
      "type": [
       "string",
       "null"
      ],
      "description": "The release signature of this version."
     },
     "kid": {
      "type": [
       "string",
       "null"
      ],
      "description": "The id of the key that signature was made with."
     }
    }
   },
   "AgentPolicy": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "allowUserQuit": {
      "type": "boolean"
     },
     "checkInIntervalMinutes": {
      "type": "integer"
     },
     "manageUpdates": {
      "type": "boolean"
     },
     "updateAutoCheck": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "Null unless manageUpdates is true."
     },
     "updateAutoDownload": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "Null unless manageUpdates is true."
     },
     "updateInstallSecurity": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "Null unless manageUpdates is true."
     },
     "updateInstallMacOS": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "Null unless manageUpdates is true."
     },
     "updateInstallApps": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "Null unless manageUpdates is true."
     },
     "manageCompliance": {
      "type": "boolean"
     },
     "complianceFirewall": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "Null unless manageCompliance is true."
     },
     "complianceDisableSSH": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "Null unless manageCompliance is true."
     },
     "complianceDisableScreenSharing": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "Null unless manageCompliance is true."
     },
     "complianceDisableGuest": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "Null unless manageCompliance is true."
     },
     "complianceDisableAutoLogin": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "Null unless manageCompliance is true."
     },
     "complianceGatekeeper": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "Null unless manageCompliance is true."
     },
     "complianceRequireScreenLock": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "Null unless manageCompliance is true."
     },
     "complianceScreenLockMinutes": {
      "type": [
       "integer",
       "null"
      ],
      "description": "Null unless manageCompliance is true."
     },
     "complianceAuditd": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "Null unless manageCompliance is true."
     },
     "complianceTimeSync": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "Null unless manageCompliance is true."
     },
     "complianceFail2ban": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "Null unless manageCompliance is true."
     },
     "complianceDefender": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "Null unless manageCompliance is true."
     },
     "complianceBitlocker": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "Null unless manageCompliance is true."
     }
    }
   },
   "AgentRecoveryKeyEscrow": {
    "description": "A signed call. One key is kept per device and key type; a later call replaces it.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "keyType": {
      "type": "string",
      "enum": [
       "bitlocker",
       "filevault"
      ],
      "description": "Read in any letter case."
     },
     "recoveryKey": {
      "type": "string"
     },
     "volumeId": {
      "type": "string",
      "description": "Cut to 255 characters."
     }
    },
    "required": [
     "keyType",
     "recoveryKey"
    ]
   },
   "AgentMdmEnrollRedeem": {
    "description": "Exchanges an enrolment code for the profile to install. code is the only key read.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "code": {
      "type": "string",
      "description": "The enrolment code: at least 6 letters or digits."
     }
    },
    "required": [
     "code"
    ]
   },
   "LoginRequest": {
    "description": "The body of the password sign-in.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "username": {
      "type": "string",
      "description": "The account email. At most 255 characters."
     },
     "password": {
      "type": "string",
      "description": "At most 1024 characters."
     },
     "consentAccepted": {
      "type": "boolean",
      "description": "true accepts the current terms in the same call, when the first answer was a LoginTermsChallenge."
     }
    },
    "required": [
     "username",
     "password"
    ]
   },
   "SessionGrant": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "auth": {
      "type": "boolean",
      "description": "Always true."
     },
     "token": {
      "type": "string",
      "description": "The session token (a JWT). Send it as Authorization: Bearer, or rely on the cookie."
     }
    }
   },
   "LoginTermsChallenge": {
    "description": "Answered with status 200 instead of a SessionGrant when the current terms are not yet accepted. No session exists yet.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "termsAcceptanceRequired": {
      "type": "boolean",
      "description": "Always true. This field tells the three challenge objects apart."
     },
     "userId": {
      "type": "integer"
     },
     "termsVersion": {
      "type": "string",
      "description": "The version of the terms to show."
     },
     "termsToken": {
      "type": "string",
      "description": "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."
     }
    }
   },
   "LoginPasswordChangeChallenge": {
    "description": "Answered with status 200 instead of a SessionGrant when the account must choose a new password first.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "passwordChangeRequired": {
      "type": "boolean",
      "description": "Always true."
     },
     "userId": {
      "type": "integer"
     },
     "requirements": {
      "type": "object",
      "description": "{ minLength: integer, requireUppercase: boolean, requireNumbers: boolean, requireSymbols: boolean }: the strictest value of every policy that applies to the account."
     },
     "passwordChangeToken": {
      "type": "string",
      "description": "The single-use token the password change call takes as its Bearer token."
     }
    }
   },
   "LoginMfaChallenge": {
    "description": "Answered with status 200 instead of a SessionGrant when a second factor is still owed.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "mfaRequired": {
      "type": "boolean",
      "description": "Always true."
     },
     "mfaStatus": {
      "type": "string",
      "description": "setup_pending (no factor enrolled yet: enrol one with the token) | challenge_pending (answer with an enrolled factor)."
     },
     "userId": {
      "type": "integer"
     },
     "mfaToken": {
      "type": "string",
      "description": "A short-lived token for the enrolment and verification calls. It is not a session."
     },
     "mfaMethods": {
      "type": "object",
      "description": "Present with challenge_pending only: { passkey: boolean, totp: boolean }, which factors the account has enrolled."
     }
    }
   },
   "CurrentUser": {
    "description": "The user object inside the reply of the signed-in user read: { auth: true, user }.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "username": {
      "type": "string",
      "description": "The account email."
     },
     "firstName": {
      "type": [
       "string",
       "null"
      ]
     },
     "lastName": {
      "type": [
       "string",
       "null"
      ]
     },
     "roles": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Role names, held directly or through a group."
     },
     "groups": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Group names."
     },
     "permissions": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Permission names, held directly, through a role or through a group."
     },
     "mfaEnabled": {
      "type": "boolean",
      "description": "The MFA flag stored on the account."
     },
     "mfaVerified": {
      "type": "boolean",
      "description": "Whether this session proved a second factor."
     },
     "mfaRequired": {
      "type": "boolean",
      "description": "Whether the account is required to use one."
     },
     "supportSession": {
      "allOf": [
       {
        "$ref": "#/components/schemas/SupportSessionInfo"
       }
      ],
      "description": "Present only in a support-access session."
     }
    }
   },
   "SupportSessionInfo": {
    "description": "Marks a session as a time-boxed support-access session.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "grantId": {
      "type": [
       "string",
       "null"
      ]
     },
     "scope": {
      "type": "string",
      "description": "read_only | role"
     },
     "roleName": {
      "type": [
       "string",
       "null"
      ],
      "description": "The role granted. Null for read_only."
     },
     "expiresAt": {
      "type": "string",
      "format": "date-time"
     }
    }
   },
   "PasswordChangeResult": {
    "description": "The reply to a forced password change. It opens no session: sign in again afterwards.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "success": {
      "type": "boolean",
      "description": "Always true."
     },
     "message": {
      "type": "string"
     },
     "devicesRevoked": {
      "type": "integer",
      "description": "How many trusted devices of the account were revoked by the change."
     }
    }
   },
   "InviteInspection": {
    "description": "Who a set-password invitation is for. Status 410 with { error } when the token is unknown, used or expired.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "valid": {
      "type": "boolean",
      "description": "Always true."
     },
     "email": {
      "type": "string"
     },
     "firstName": {
      "type": "string",
      "description": "Empty string when the account has none."
     },
     "expiresAt": {
      "type": "string",
      "format": "date-time"
     },
     "policy": {
      "type": "object",
      "description": "{ minLength: integer, requireUppercase: boolean, requireNumbers: boolean, requireSymbols: boolean }: the strictest value of every policy that applies to the account."
     }
    }
   },
   "InviteAcceptRequest": {
    "description": "Sets the password of the invited account. The reply is { success: true, email } and opens no session.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "token": {
      "type": "string",
      "description": "The invitation token. At most 128 characters."
     },
     "password": {
      "type": "string",
      "description": "At most 1024 characters, and it must meet the policy the inspection returned."
     }
    },
    "required": [
     "token",
     "password"
    ]
   },
   "TotpSetup": {
    "description": "The reply to starting authenticator-app enrolment. The secret is pending until it is confirmed with a code.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "secret": {
      "type": "string",
      "description": "The shared secret, base32."
     },
     "otpauth_url": {
      "type": "string",
      "description": "The same secret as an otpauth:// address, for a QR code."
     }
    }
   },
   "MfaEnrolmentResult": {
    "description": "The reply to confirming an authenticator app. Registering a passkey answers verified: true in place of auth, with recoveryCodes under the same rule.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "auth": {
      "type": "boolean",
      "description": "Always true."
     },
     "token": {
      "type": "string",
      "description": "A session token that carries the proven second factor."
     },
     "recoveryCodes": {
      "type": [
       "array",
       "null"
      ],
      "items": {
       "type": "string"
      },
      "description": "The recovery codes in clear, shown this once. Null when the account already had recovery codes."
     }
    }
   },
   "MfaVerifyRequest": {
    "description": "Answers a LoginMfaChallenge with an authenticator-app code. The recovery-code call takes recoveryCode (string, required) in place of token.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "mfaToken": {
      "type": "string",
      "description": "The mfaToken of the challenge."
     },
     "token": {
      "type": "string",
      "description": "The current code from the authenticator app. A code is accepted once."
     },
     "rememberDevice": {
      "type": "boolean",
      "description": "true also registers this browser as a trusted device."
     }
    },
    "required": [
     "mfaToken",
     "token"
    ]
   },
   "MfaVerifyResult": {
    "description": "The reply to a second factor answered with a code or a recovery code.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "auth": {
      "type": "boolean",
      "description": "Always true."
     },
     "token": {
      "type": "string",
      "description": "The session token, also set as the session cookie."
     },
     "trustedDeviceToken": {
      "type": [
       "string",
       "null"
      ],
      "description": "The trusted-device token when rememberDevice was true, otherwise null. When it is set, the gateway also sets it as an httpOnly cookie."
     }
    }
   },
   "PasskeyRegistrationOptions": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "challenge": {
      "type": "string",
      "description": "base64url. Kept by the server until the registration is verified."
     },
     "rp": {
      "type": "object",
      "description": "{ name, id }: the instance."
     },
     "user": {
      "type": "object",
      "description": "{ id, name, displayName }: name and displayName are the account email."
     },
     "pubKeyCredParams": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Set by the library."
     },
     "timeout": {
      "type": "integer",
      "description": "Milliseconds. Set by the library."
     },
     "attestation": {
      "type": "string",
      "description": "none"
     },
     "excludeCredentials": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "The passkeys the account already has."
     },
     "authenticatorSelection": {
      "type": "object",
      "description": "residentKey: preferred, userVerification: required."
     }
    }
   },
   "PasskeyList": {
    "description": "The signed-in user’s own second factors.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "credentials": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Oldest first. Each { id: integer, transports: string[], createdAt: string (date-time) }. The public key is not included."
     },
     "totpEnrolled": {
      "type": "boolean",
      "description": "Whether an authenticator app is enrolled."
     },
     "recoveryCodesRemaining": {
      "type": "integer",
      "description": "Unused recovery codes."
     }
    }
   },
   "TrustedDevice": {
    "description": "One remembered browser. The trusted-device read answers { devices: TrustedDevice[] }, most recently used first, every device in one reply.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "userAgent": {
      "type": [
       "string",
       "null"
      ]
     },
     "ipAddress": {
      "type": [
       "string",
       "null"
      ]
     },
     "lastUsedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "expiresAt": {
      "type": "string",
      "format": "date-time",
      "description": "After this the browser is asked for a second factor again."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "current": {
      "type": "boolean",
      "description": "True for the browser the call came from."
     }
    }
   },
   "FactorResetResult": {
    "description": "The reply to an administrator resetting another user’s second factors. Revoking only their trusted devices answers { ok, email, revoked: integer }.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "ok": {
      "type": "boolean",
      "description": "Always true."
     },
     "email": {
      "type": "string",
      "description": "The account that was reset."
     },
     "passkeysRemoved": {
      "type": "integer"
     },
     "devicesRevoked": {
      "type": "integer"
     }
    }
   },
   "PasswordChangeRequest": {
    "description": "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.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "newPassword": {
      "type": "string",
      "description": "Must not be empty, and must meet the password rules that apply to the account."
     }
    },
    "required": [
     "newPassword"
    ]
   },
   "StepUpRequest": {
    "description": "The session is the one in the Authorization: Bearer header.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "password": {
      "type": "string",
      "description": "The password of the signed-in account."
     }
    },
    "required": [
     "password"
    ]
   },
   "SignInLinkRequest": {
    "description": "Asks for a sign-in link by email.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "email": {
      "type": "string",
      "description": "At most 255 characters."
     }
    },
    "required": [
     "email"
    ]
   },
   "MfaSetupStart": {
    "description": "Every key is optional.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "mfaToken": {
      "type": "string",
      "description": "The token a sign-in answered when it asked for a second factor. Left out, the token in the Authorization: Bearer header is read."
     }
    }
   },
   "MfaSetupConfirm": {
    "description": "Confirms an authenticator app with its first code.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "token": {
      "type": "string",
      "description": "The code the authenticator app shows."
     },
     "mfaToken": {
      "type": "string",
      "description": "The token a sign-in answered when it asked for a second factor. Left out, the token in the Authorization: Bearer header is read."
     }
    },
    "required": [
     "token"
    ]
   },
   "MfaRecoveryVerify": {
    "description": "Completes a sign-in with a recovery code.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "mfaToken": {
      "type": "string",
      "description": "The token the sign-in answered when it asked for a second factor."
     },
     "recoveryCode": {
      "type": "string"
     },
     "rememberDevice": {
      "type": "boolean"
     }
    },
    "required": [
     "mfaToken",
     "recoveryCode"
    ]
   },
   "SsoStatus": {
    "description": "What the sign-in page reads before anyone is signed in. { enabled: false } alone when the identity service cannot be reached.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "enabled": {
      "type": "boolean"
     },
     "protocol": {
      "type": "string",
      "description": "oidc | saml"
     },
     "preset": {
      "type": "string",
      "description": "okta | entra | google | other. Present only while single sign-on is on."
     },
     "smtpConfigured": {
      "type": "boolean",
      "description": "Whether the instance can send email, and so offer the emailed sign-in link."
     }
    }
   },
   "SsoConfig": {
    "description": "The single sign-on configuration as an administrator reads it and as a save answers it. The client secret is never returned.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "enabled": {
      "type": "boolean"
     },
     "protocol": {
      "type": "string",
      "description": "oidc | saml. oidc when none is stored."
     },
     "preset": {
      "type": "string",
      "description": "okta | entra | google | other. other when none is stored."
     },
     "issuer": {
      "type": "string",
      "description": "Empty string when unset."
     },
     "clientId": {
      "type": "string",
      "description": "Empty string when unset."
     },
     "clientSecretSet": {
      "type": "boolean",
      "readOnly": true,
      "description": "Whether a client secret is stored."
     },
     "saml": {
      "type": "object",
      "description": "{ idpEntityId: string, idpSsoUrl: string, certs: SsoCertificate[] }"
     },
     "jit": {
      "type": "object",
      "description": "{ enabled, allowedDomains, defaultRoleId, defaultGroupId }: creating an account on first sign-in."
     },
     "mfaAcrValues": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Empty array when unset."
     }
    }
   },
   "SsoCertificate": {
    "description": "A summary of one identity-provider signing certificate. The certificate itself is not returned by the configuration read.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "subject": {
      "type": "string",
      "description": "unparseable when the stored certificate cannot be read."
     },
     "notAfter": {
      "type": [
       "string",
       "null"
      ],
      "description": "The expiry as the certificate states it."
     },
     "fingerprint256": {
      "type": [
       "string",
       "null"
      ],
      "description": "SHA-256 fingerprint."
     },
     "expired": {
      "type": "boolean"
     }
    }
   },
   "SsoPendingStep": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "mfaRequired": {
      "type": "boolean"
     },
     "mfaStatus": {
      "type": "string",
      "description": "setup_pending | challenge_pending"
     },
     "userId": {
      "type": "integer"
     },
     "mfaToken": {
      "type": "string"
     },
     "mfaMethods": {
      "type": "object",
      "description": "{ passkey: boolean, totp: boolean }"
     },
     "termsAcceptanceRequired": {
      "type": "boolean"
     },
     "termsToken": {
      "type": "string"
     },
     "termsVersion": {
      "type": "string"
     }
    }
   },
   "ScimUser": {
    "description": "A SCIM 2.0 User as the instance returns it. A field marked \"when set\" is left out, not sent as null.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "schemas": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "The core User schema and the enterprise User extension."
     },
     "id": {
      "type": "string",
      "readOnly": true,
      "description": "The Zonitix user id, as a string."
     },
     "userName": {
      "type": "string",
      "description": "The userName the identity provider sent, otherwise the account email."
     },
     "externalId": {
      "type": "string",
      "description": "When set."
     },
     "name": {
      "type": "object",
      "description": "{ givenName, familyName, formatted }: empty strings when the account has no name."
     },
     "displayName": {
      "type": "string",
      "description": "First and last name. Left out when both are empty."
     },
     "emails": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "One entry: { value, type: \"work\", primary: true }."
     },
     "active": {
      "type": "boolean",
      "description": "False for an account that is offboarded, disabled, inactive, terminated, archived, deleted or suspended. True for every other status."
     },
     "title": {
      "type": "string",
      "description": "When set."
     },
     "phoneNumbers": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "When set: one entry { value, type: \"work\" }."
     },
     "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
      "type": "object",
      "description": "Any of department, employeeNumber and manager: { value }, each when set. An empty object otherwise."
     },
     "meta": {
      "type": "object",
      "readOnly": true,
      "description": "{ resourceType: \"User\", created, lastModified, location }"
     }
    }
   },
   "ScimUserWrite": {
    "description": "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.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "userName": {
      "type": "string",
      "description": "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."
     },
     "externalId": {
      "type": "string",
      "description": "At most 255 characters. null clears it."
     },
     "emails": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "The entry marked primary is used; otherwise the entry of type work; otherwise the first entry with a value."
     },
     "name": {
      "type": "object",
      "description": "{ givenName, familyName }"
     },
     "displayName": {
      "type": "string",
      "description": "Split into first and last name only when no name object is sent."
     },
     "title": {
      "type": "string",
      "description": "Stored as the position."
     },
     "active": {
      "type": "boolean"
     },
     "phoneNumbers": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "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."
     },
     "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
      "type": "object",
      "description": "department, employeeNumber (at most 64 characters) and manager (a user id, or { value })."
     }
    }
   },
   "ScimGroup": {
    "description": "A SCIM 2.0 Group as the instance returns it.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "schemas": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "The core Group schema."
     },
     "id": {
      "type": "string",
      "readOnly": true,
      "description": "The Zonitix group id, as a string."
     },
     "displayName": {
      "type": "string",
      "description": "The group name."
     },
     "externalId": {
      "type": "string",
      "description": "Only for a group that SCIM created and that has one."
     },
     "members": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { value: the user id as a string, display, $ref }. Left out when the request excludes members."
     },
     "meta": {
      "type": "object",
      "readOnly": true,
      "description": "{ resourceType: \"Group\", created, lastModified, location }"
     }
    }
   },
   "ScimListResponse": {
    "description": "The envelope of the SCIM user and group lists. Paging is by startIndex (1-based, default 1) and count (default 100, at most 200).",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "schemas": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "The SCIM ListResponse schema."
     },
     "totalResults": {
      "type": "integer",
      "description": "Every match, not only this page."
     },
     "startIndex": {
      "type": "integer"
     },
     "itemsPerPage": {
      "type": "integer",
      "description": "The number of resources in this reply."
     },
     "Resources": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "ScimUser or ScimGroup objects."
     }
    }
   },
   "ScimError": {
    "description": "The body of every refused SCIM call.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "schemas": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "The SCIM Error schema."
     },
     "status": {
      "type": "string",
      "description": "The HTTP status, as a string."
     },
     "detail": {
      "type": "string",
      "description": "The reason."
     },
     "scimType": {
      "type": "string",
      "description": "Present on some refusals, for example invalidFilter, invalidValue, uniqueness or mutability."
     }
    }
   },
   "ScimToken": {
    "description": "One SCIM bearer token record. The token value is returned once, as token, by the call that creates it.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "name": {
      "type": "string"
     },
     "tokenPrefix": {
      "type": "string",
      "readOnly": true,
      "description": "The first characters of the value, to recognise it by."
     },
     "createdBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "lastUsedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "revokedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "revokedBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "familyId": {
      "type": "string",
      "readOnly": true,
      "description": "The connection the token belongs to. A token made with rotateFromId keeps the family of the one it replaces."
     },
     "active": {
      "type": "boolean",
      "readOnly": true,
      "description": "False once revoked."
     },
     "owns": {
      "type": [
       "object",
       "null"
      ],
      "readOnly": true,
      "description": "In the token list only: { users: integer, groups: integer } owned by the token’s connection. Null when the counts could not be read."
     }
    }
   },
   "ScimSettings": {
    "description": "The provisioning policy and the groups it can cover. A save answers the policy object alone.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "policy": {
      "type": "object",
      "description": "{ onboardOnCreate: boolean, allowGroupCreate: boolean, managedGroupIds: integer[], privilegedAllowedGroupIds: integer[] }"
     },
     "groups": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { id, name, source, scimCreated: boolean, eligible: boolean, managed: boolean, privileged: boolean, confers: string[], privilegedAllowed: boolean }."
     }
    }
   },
   "AccessReviewCampaign": {
    "description": "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).",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "name": {
      "type": "string"
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     },
     "status": {
      "type": "string",
      "readOnly": true,
      "description": "active | completed | archived"
     },
     "scopeType": {
      "type": "string",
      "description": "all | department"
     },
     "scopeValue": {
      "type": [
       "string",
       "null"
      ],
      "description": "The department, for a department scope."
     },
     "createdBy": {
      "type": "string",
      "readOnly": true
     },
     "dueDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date"
     },
     "itemCount": {
      "type": "integer",
      "readOnly": true
     },
     "decidedCount": {
      "type": "integer",
      "readOnly": true
     },
     "revokeCount": {
      "type": "integer",
      "readOnly": true
     },
     "completedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "scheduleId": {
      "type": [
       "integer",
       "null"
      ],
      "readOnly": true,
      "description": "The recurring schedule that launched it."
     },
     "lastReminderAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "AccessReviewCampaignCreate": {
    "description": "Starts a review and creates its items.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string"
     },
     "description": {
      "type": "string"
     },
     "scopeType": {
      "type": "string",
      "description": "all (default) | department"
     },
     "scopeValue": {
      "type": "string",
      "description": "The department. Required for a department scope."
     },
     "dueDate": {
      "type": "string",
      "format": "date"
     },
     "externalSource": {
      "type": "string",
      "description": "none | persona | actual | both. Default persona."
     }
    },
    "required": [
     "name"
    ]
   },
   "AccessReviewItem": {
    "description": "One piece of access to decide, as the campaign read and the reviewer queue return it.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "campaignId": {
      "type": "integer",
      "readOnly": true
     },
     "userId": {
      "type": "integer",
      "readOnly": true,
      "description": "The person whose access it is."
     },
     "userEmail": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "reviewerEmail": {
      "type": "string",
      "description": "Who is asked to decide."
     },
     "accessType": {
      "type": "string",
      "readOnly": true,
      "description": "role | group | app | license"
     },
     "accessId": {
      "type": "integer",
      "readOnly": true,
      "description": "The role or group id. 0 for an application or licence."
     },
     "accessLabel": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "providerType": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "runId": {
      "type": [
       "integer",
       "null"
      ],
      "readOnly": true,
      "description": "The workflow run that carries out a revoke."
     },
     "drift": {
      "type": "boolean",
      "readOnly": true
     },
     "decision": {
      "type": "string",
      "description": "pending | keep | revoke"
     },
     "decidedBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "decidedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "note": {
      "type": [
       "string",
       "null"
      ]
     },
     "revoked": {
      "type": "boolean",
      "readOnly": true
     },
     "reroutedFrom": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true,
      "description": "The reviewer the item was moved away from."
     },
     "reroutedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "rerouteReason": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "campaignName": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "campaignDueDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date",
      "readOnly": true
     },
     "classification": {
      "type": "string",
      "readOnly": true,
      "description": "directory (a role or a group) | blueprint (an application the person’s persona expects) | drift (an application outside it)."
     },
     "permissionCount": {
      "type": [
       "integer",
       "null"
      ],
      "readOnly": true,
      "description": "How many permissions the role or group carries. Null for an application or licence."
     },
     "privileged": {
      "type": "boolean",
      "readOnly": true,
      "description": "True for the administrator role, for a role or group carrying any manage_ permission, and for one carrying 15 or more permissions."
     }
    }
   },
   "AccessReviewCampaignDetail": {
    "description": "One campaign with a page of its items, ordered by person then access type. Paged by ?limit (default 200, 1 to 500) and ?offset.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "campaign": {
      "$ref": "#/components/schemas/AccessReviewCampaign"
     },
     "items": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/AccessReviewItem"
      }
     },
     "total": {
      "type": "integer",
      "description": "Every item of the campaign."
     },
     "limit": {
      "type": "integer",
      "description": "The limit applied."
     },
     "offset": {
      "type": "integer"
     }
    }
   },
   "AccessReviewQueue": {
    "description": "The pending items assigned to the caller in active campaigns. Paged by ?limit (default 50, 1 to 200) and ?offset.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "items": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/AccessReviewItem"
      }
     },
     "total": {
      "type": "integer",
      "description": "Every item matching the filter."
     },
     "limit": {
      "type": "integer"
     },
     "offset": {
      "type": "integer"
     },
     "filter": {
      "type": "string",
      "description": "The filter applied. all when the one sent is not recognised."
     },
     "stats": {
      "type": "object",
      "description": "{ all, attention, expected, privileged, drift, people }, each an integer."
     }
    }
   },
   "AccessGrantDetail": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "accessType": {
      "type": [
       "string",
       "null"
      ],
      "description": "role | group, or the value sent for any other type. Null when none was sent."
     },
     "accessId": {
      "type": [
       "integer",
       "null"
      ],
      "description": "Null when none was sent."
     },
     "accessLabel": {
      "type": [
       "string",
       "null"
      ],
      "description": "The role or group name."
     },
     "external": {
      "type": "boolean",
      "description": "False for a role or group."
     },
     "permissions": {
      "type": [
       "array",
       "null"
      ],
      "items": {
       "type": "string"
      },
      "description": "Permission names, sorted."
     },
     "permissionCount": {
      "type": [
       "integer",
       "null"
      ]
     },
     "privileged": {
      "type": "boolean"
     }
    }
   },
   "AccessReviewSchedule": {
    "description": "A recurring review. The schedule list is a bare array, newest first, every schedule in one reply.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "name": {
      "type": "string"
     },
     "scopeType": {
      "type": "string",
      "description": "all | department"
     },
     "scopeValue": {
      "type": [
       "string",
       "null"
      ]
     },
     "externalSource": {
      "type": "string",
      "description": "none | persona | actual | both. Default both."
     },
     "cadence": {
      "type": "string",
      "description": "monthly | quarterly (default) | semiannual | annual | custom"
     },
     "intervalMonths": {
      "type": [
       "integer",
       "null"
      ],
      "description": "For a custom cadence."
     },
     "dueDays": {
      "type": "integer",
      "description": "Default 14."
     },
     "status": {
      "type": "string",
      "description": "active | paused"
     },
     "remind": {
      "type": "boolean",
      "description": "Default true."
     },
     "autoClose": {
      "type": "boolean",
      "description": "Default true."
     },
     "skipIfOpen": {
      "type": "boolean",
      "description": "Default false."
     },
     "nextRunAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "lastRunAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "createdBy": {
      "type": "string",
      "readOnly": true
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "LiveAccessSnapshot": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "generatedAt": {
      "type": "string",
      "format": "date-time"
     },
     "scopeType": {
      "type": "string",
      "description": "all | department"
     },
     "scopeValue": {
      "type": [
       "string",
       "null"
      ]
     },
     "externalSource": {
      "type": "string",
      "description": "none | persona | actual | both"
     },
     "userCount": {
      "type": "integer"
     },
     "total": {
      "type": "integer",
      "description": "Items before the cut."
     },
     "driftCount": {
      "type": "integer"
     },
     "privilegedCount": {
      "type": "integer"
     },
     "providersActive": {
      "type": "integer"
     },
     "externalScanned": {
      "type": "boolean",
      "description": "True when actual usage was read (externalSource actual or both)."
     },
     "capped": {
      "type": "boolean"
     },
     "items": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { userId, userEmail, department, accessType, accessId, accessLabel, providerType, drift, classification, permissionCount, privileged, liveKey }."
     }
    }
   },
   "AccessRequest": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "userId": {
      "type": "integer",
      "description": "The person the access is for."
     },
     "requestedBy": {
      "type": "string",
      "readOnly": true
     },
     "itemKind": {
      "type": "string",
      "description": "Default app."
     },
     "itemLabel": {
      "type": "string"
     },
     "providerType": {
      "type": [
       "string",
       "null"
      ]
     },
     "justification": {
      "type": [
       "string",
       "null"
      ]
     },
     "customFields": {
      "type": [
       "object",
       "null"
      ],
      "description": "Values keyed by custom-field key."
     },
     "status": {
      "type": "string",
      "readOnly": true,
      "description": "pending | approved | fulfilled | declined | rejected | failed"
     },
     "approverEmail": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "decisionComment": {
      "type": [
       "string",
       "null"
      ]
     },
     "decidedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "runId": {
      "type": [
       "integer",
       "null"
      ],
      "readOnly": true,
      "description": "The workflow run that provisions an approved request."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "LogForwardDestination": {
    "description": "One log-forwarding destination. The secret is never returned. The destination read answers { destinations, types, datadogSites, health }, every destination in one reply, id ascending.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "name": {
      "type": "string",
      "description": "At most 80 characters."
     },
     "type": {
      "type": "string",
      "description": "webhook | splunk_hec | datadog | syslog_tls. Cannot be changed after creation."
     },
     "typeLabel": {
      "type": "string",
      "readOnly": true
     },
     "enabled": {
      "type": "boolean"
     },
     "url": {
      "type": [
       "string",
       "null"
      ]
     },
     "port": {
      "type": [
       "integer",
       "null"
      ]
     },
     "config": {
      "type": "object",
      "description": "Type-specific settings. Empty object when none."
     },
     "secretSet": {
      "type": "boolean",
      "readOnly": true,
      "description": "Whether a secret is stored."
     },
     "cursorSeq": {
      "type": [
       "integer",
       "null"
      ],
      "readOnly": true,
      "description": "The sequence number of the last audit event the destination acknowledged."
     },
     "lastAckAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "lastAttemptAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "lastError": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "lastErrorAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "consecutiveFailures": {
      "type": "integer",
      "readOnly": true
     },
     "nextAttemptAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "totalSent": {
      "type": "integer",
      "readOnly": true
     },
     "skippedCount": {
      "type": "integer",
      "readOnly": true
     },
     "lastSkip": {
      "type": [
       "object",
       "null"
      ],
      "readOnly": true
     },
     "gapCount": {
      "type": "integer",
      "readOnly": true
     },
     "lagEvents": {
      "type": [
       "integer",
       "null"
      ],
      "readOnly": true,
      "description": "In the list only: audit events not yet acknowledged."
     },
     "lagSeconds": {
      "type": [
       "integer",
       "null"
      ],
      "readOnly": true,
      "description": "In the list only."
     },
     "createdBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "updatedBy": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "createdAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "LogForwardingHealth": {
    "description": "The health block of the destination read. { error } alone when the state cannot be read.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "configured": {
      "type": "integer",
      "description": "Destinations that exist."
     },
     "enabled": {
      "type": "integer",
      "description": "Destinations switched on."
     },
     "failing": {
      "type": "integer",
      "description": "Enabled destinations whose last attempt failed."
     },
     "stale": {
      "type": "integer",
      "description": "Enabled destinations with a backlog and no acknowledgement for 15 minutes."
     },
     "maxLagEvents": {
      "type": "integer"
     },
     "maxLagSeconds": {
      "type": "integer"
     },
     "degraded": {
      "type": "boolean",
      "description": "True when any destination is failing or stale."
     },
     "lastTick": {
      "type": [
       "object",
       "null"
      ],
      "description": "{ at: string (date-time), status: worked | idle | failed, summary }: the last delivery sweep. Null before the first one."
     },
     "destinations": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { id, type, enabled, lagEvents, lagSeconds, consecutiveFailures, lastAckAt, skipped, gaps, stale }."
     }
    }
   },
   "SupportAccessStatus": {
    "description": "The state of vendor support access. No credential is ever part of it.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "active": {
      "type": "boolean"
     },
     "grant": {
      "anyOf": [
       {
        "$ref": "#/components/schemas/SupportAccessGrant"
       },
       {
        "type": "null"
       }
      ],
      "description": "The grant in force. Null when none is active."
     },
     "account": {
      "type": [
       "object",
       "null"
      ],
      "description": "{ email, status, mfaEnrolled: boolean, invitePending: boolean, lastActiveAt }. Null when no support account exists."
     },
     "history": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Up to 20 ended grants, newest first: each a SupportAccessGrant plus endedAt, endReason (revoked | expired) and endedBy."
     },
     "limits": {
      "type": "object",
      "description": "{ minHours: 1, maxHours: 72 }"
     },
     "readOnlyPermissions": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "The permissions a read_only grant carries."
     },
     "supportMailbox": {
      "type": "string",
      "description": "The address invitations are suggested for. Empty string when none is saved."
     }
    }
   },
   "SupportAccessGrant": {
    "description": "One grant of support access. Granting answers { grant, invite }, where invite is { sent: boolean, expiresAt?, reason?, link? }.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "grantId": {
      "type": "string",
      "readOnly": true,
      "description": "A UUID."
     },
     "scope": {
      "type": "string",
      "description": "read_only | role"
     },
     "roleName": {
      "type": [
       "string",
       "null"
      ],
      "description": "The role granted. Null for read_only."
     },
     "grantedBy": {
      "type": "string",
      "readOnly": true
     },
     "grantedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "expiresAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "hours": {
      "type": "integer",
      "description": "1 to 72."
     },
     "reason": {
      "type": "string",
      "description": "5 to 500 characters."
     },
     "email": {
      "type": "string",
      "description": "The support address the invitation went to."
     },
     "administratorAcknowledged": {
      "type": "boolean",
      "readOnly": true,
      "description": "True when the role granted is administrator."
     }
    }
   },
   "ApiTokenAdminRevoke": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reason": {
      "type": "string",
      "description": "Cut to 200 characters and stored as revokeReason. Without it the record says \"Revoked by an administrator\"."
     }
    }
   },
   "ScimTokenIssue": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string"
     },
     "rotateFromId": {
      "type": "integer",
      "description": "The id of the token this one replaces, so it keeps that connection’s users and groups. 404 when no token has that id."
     }
    },
    "required": [
     "name"
    ]
   },
   "ScimTokenRebind": {
    "description": "Send one of the two keys.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "fromTokenId": {
      "type": "integer",
      "description": "Move the users and groups of this token’s connection to the token in the path."
     },
     "unowned": {
      "type": "boolean",
      "description": "True moves the users and groups that belong to no connection."
     }
    }
   },
   "AccessReviewDecision": {
    "description": "The bulk call takes the same keys plus ids (1 to 500 item ids).",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "decision": {
      "type": "string",
      "enum": [
       "keep",
       "revoke"
      ]
     },
     "note": {
      "type": "string"
     }
    },
    "required": [
     "decision"
    ]
   },
   "AccessReviewReassign": {
    "description": "The bulk call takes the same key plus ids (1 to 500 item ids).",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reviewerEmail": {
      "type": "string",
      "description": "An active user, and not the person whose access the item is."
     }
    },
    "required": [
     "reviewerEmail"
    ]
   },
   "AccessReviewScheduleWrite": {
    "description": "The body of the schedule create call. The update call reads the same keys, all optional.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string"
     },
     "cadence": {
      "type": "string",
      "enum": [
       "monthly",
       "quarterly",
       "semiannual",
       "annual",
       "custom"
      ],
      "description": "Default quarterly; an unknown value is read as quarterly."
     },
     "intervalMonths": {
      "type": "integer",
      "description": "Custom cadence only. At least 1."
     },
     "scopeType": {
      "type": "string",
      "enum": [
       "all",
       "department"
      ],
      "description": "Default all."
     },
     "scopeValue": {
      "type": "string",
      "description": "The department, for a department scope."
     },
     "externalSource": {
      "type": "string",
      "enum": [
       "none",
       "persona",
       "actual",
       "both"
      ],
      "description": "Default both."
     },
     "dueDays": {
      "type": "integer",
      "description": "Held to 1 to 365. Default 14."
     },
     "remind": {
      "type": "boolean",
      "description": "Default true."
     },
     "autoClose": {
      "type": "boolean",
      "description": "Default true."
     },
     "skipIfOpen": {
      "type": "boolean",
      "description": "Default false."
     },
     "firstRunAt": {
      "type": "string",
      "format": "date-time",
      "description": "Default now."
     }
    },
    "required": [
     "name"
    ]
   },
   "LiveAccessRevoke": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "userId": {
      "type": "integer"
     },
     "accessType": {
      "type": "string"
     },
     "accessLabel": {
      "type": "string"
     },
     "accessId": {
      "type": "integer",
      "description": "Required to revoke a role or a group."
     },
     "providerType": {
      "type": "string"
     }
    },
    "required": [
     "userId",
     "accessType",
     "accessLabel"
    ]
   },
   "AccessRequestCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "itemLabel": {
      "type": "string",
      "description": "What is being asked for."
     },
     "itemKind": {
      "type": "string",
      "description": "Default app."
     },
     "providerType": {
      "type": "string"
     },
     "justification": {
      "type": "string"
     },
     "customFields": {
      "type": "object",
      "description": "Values keyed by custom field key. Only keys in the custom field list are kept, 500 characters each."
     },
     "userId": {
      "type": "integer",
      "description": "Who the request is for. Honoured only for an approver or a holder of manage_users; otherwise the request is for the caller."
     }
    },
    "required": [
     "itemLabel"
    ]
   },
   "AccessRequestDecision": {
    "description": "The bulk call takes the same keys plus ids (1 to 500 request ids).",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "approve": {
      "type": "boolean",
      "description": "True approves. Anything else, or leaving it out, rejects."
     },
     "comment": {
      "type": "string"
     }
    }
   },
   "AccessRequestFieldsWrite": {
    "description": "Replaces the whole list of custom fields on the request form.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "fields": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "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."
     }
    }
   },
   "DepartmentCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string",
      "description": "At most 100 characters."
     }
    },
    "required": [
     "name"
    ]
   },
   "UserOffboard": {
    "description": "All keys are optional.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "lastDay": {
      "type": "string",
      "description": "The person’s last day."
     },
     "immediate": {
      "type": "boolean",
      "description": "True runs the revoke steps at once, and then reason is required."
     },
     "reason": {
      "type": "string",
      "description": "At least 5 characters when immediate is true."
     }
    }
   },
   "UserOnboard": {
    "description": "All keys are optional.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "personaId": {
      "type": "integer"
     },
     "startDate": {
      "type": "string"
     },
     "managerId": {
      "type": "integer"
     }
    }
   },
   "DottedReportsUpdate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "managerIds": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "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."
     }
    }
   },
   "RolePermissionsReplace": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "permissionIds": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "Replaces the role’s permission set."
     }
    },
    "required": [
     "permissionIds"
    ]
   },
   "OrgSnapshotCreate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string"
     }
    },
    "required": [
     "name"
    ]
   },
   "PasswordExpiryBatch": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "userIds": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "1 to 500 user ids. Repeated ids are counted once."
     }
    },
    "required": [
     "userIds"
    ]
   },
   "SupportAccessGrantRequest": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "hours": {
      "type": "integer",
      "description": "A whole number from 1 to 72."
     },
     "email": {
      "type": "string",
      "format": "email",
      "description": "The support address the invitation goes to. At most 254 characters."
     },
     "reason": {
      "type": "string",
      "description": "5 to 500 characters after trimming. Recorded in the audit log."
     },
     "scope": {
      "type": "string",
      "enum": [
       "read_only",
       "role"
      ],
      "description": "Default read_only."
     },
     "roleName": {
      "type": "string",
      "description": "Required for scope role."
     },
     "acknowledgeAdministrator": {
      "type": "boolean",
      "description": "Must be true when roleName is administrator."
     }
    },
    "required": [
     "hours",
     "email",
     "reason"
    ]
   },
   "SupportMailboxUpdate": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "email": {
      "type": "string",
      "description": "A valid address of at most 254 characters. Empty, null or left out clears the stored address."
     }
    }
   },
   "PasswordPolicy": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "description": "default is the organisation-wide policy. Any other id is a policy scoped to groups."
     },
     "name": {
      "type": "string"
     },
     "minLength": {
      "type": "integer",
      "description": "8 to 128."
     },
     "requireUppercase": {
      "type": "boolean"
     },
     "requireNumbers": {
      "type": "boolean"
     },
     "requireSymbols": {
      "type": "boolean"
     },
     "groupIds": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "The groups it applies to. Empty on default."
     }
    }
   },
   "SecuritySettings": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "mfaEnabled": {
      "type": "boolean",
      "description": "The organisation-wide default that groups set to inherit follow."
     },
     "passwordPolicies": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/PasswordPolicy"
      }
     },
     "passwordHistoryCount": {
      "type": "integer",
      "description": "0 to 24."
     },
     "namingConvention": {
      "type": "string",
      "description": "[first_name].[last_name] when none is stored."
     },
     "emailDomain": {
      "type": "string",
      "description": "Empty string when unset."
     },
     "conflictStrategy": {
      "type": "string",
      "description": "numeric when none is stored."
     },
     "sessionTimeout": {
      "type": "string",
      "description": "A duration such as 1h, which is the value when none is stored. 5m to 24h."
     },
     "auditLogRetention": {
      "type": "object",
      "description": "{ days, source, minDays, maxDays }: the retention in force and its limits."
     },
     "auditLogRetentionDays": {
      "type": "integer",
      "description": "90 to 3650."
     },
     "workflowRequireSeparateApprover": {
      "type": "boolean",
      "description": "True unless switched off."
     },
     "workflowRequireSeparateRunApprover": {
      "type": "boolean",
      "description": "True unless switched off."
     },
     "reportRecipientPolicy": {
      "type": "string",
      "description": "open when none is stored."
     },
     "reportRecipientDomains": {
      "type": "string",
      "description": "Empty string when unset."
     },
     "awsMappingEnabled": {
      "type": "boolean",
      "description": "A module switch. False when never set."
     },
     "azureMappingEnabled": {
      "type": "boolean",
      "description": "A module switch."
     },
     "gcpMappingEnabled": {
      "type": "boolean",
      "description": "A module switch."
     },
     "securityLabEnabled": {
      "type": "boolean",
      "description": "A module switch."
     },
     "complianceEnabled": {
      "type": "boolean",
      "description": "A module switch."
     },
     "reportingEnabled": {
      "type": "boolean",
      "description": "A module switch."
     },
     "financeEnabled": {
      "type": "boolean",
      "description": "A module switch."
     },
     "activeAiModel": {
      "type": "string",
      "description": "The model the local AI uses. Always one on the allow-list."
     },
     "aiModelRouting": {
      "type": "object",
      "description": "Empty object when unset."
     },
     "aiEnabled": {
      "type": "boolean",
      "description": "True unless switched off."
     },
     "aiRequireUsePermission": {
      "type": "boolean",
      "description": "False unless switched on."
     },
     "smtpConfigured": {
      "type": "boolean",
      "description": "True when a mail host, user and password are all stored."
     },
     "branding": {
      "type": "object",
      "description": "{ companyName: string, logoUrl: string or null, primaryBrandColor: string }"
     },
     "itNotificationRecipients": {
      "type": "string",
      "description": "Restricted."
     },
     "hrNotificationRecipients": {
      "type": "string",
      "description": "Restricted."
     },
     "smtpHost": {
      "type": "string",
      "description": "Restricted."
     },
     "smtpPort": {
      "type": "integer",
      "description": "Restricted."
     },
     "smtpUser": {
      "type": "string",
      "description": "Restricted."
     },
     "smtpFrom": {
      "type": "string",
      "description": "Restricted."
     },
     "smtpSecure": {
      "type": "boolean",
      "description": "Restricted."
     },
     "smtpPassSet": {
      "type": "boolean",
      "description": "Restricted. Whether a password is stored."
     }
    }
   },
   "AccessControlRules": {
    "description": "The network access rules: what is stored, laid over the defaults. A reply carries further fields beyond these.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "enabled": {
      "type": "boolean",
      "description": "Default false."
     },
     "mode": {
      "type": "string",
      "description": "allowlist | blocklist. Default blocklist."
     },
     "countries": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Default empty."
     },
     "blockedIps": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Default empty."
     },
     "allowedIps": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Default empty."
     },
     "blockUnknownGeo": {
      "type": "boolean",
      "description": "Default false."
     },
     "dryRun": {
      "type": "boolean",
      "description": "Default false."
     },
     "autoBlock": {
      "type": "object",
      "description": "{ enabled (default false), dryRun (default true), threshold (default 6), windowSec (default 600), banSec (default 21600), patterns, tuning }"
     },
     "geoDbLoaded": {
      "type": "boolean",
      "readOnly": true,
      "description": "Added by the gateway: whether its offline country database loaded. Country rules do nothing when false."
     }
    }
   },
   "HookProvider": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "name": {
      "type": "string"
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     },
     "type": {
      "type": "string",
      "description": "The kind of system, for example OKTA or AWS."
     },
     "config": {
      "type": "object",
      "description": "The connection settings, without secret values."
     },
     "configuredCredentials": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "readOnly": true,
      "description": "The names of the secrets that are set."
     },
     "isActive": {
      "type": "boolean",
      "description": "Default true."
     },
     "canReadUsers": {
      "type": "boolean",
      "description": "Default false."
     },
     "canReadGroups": {
      "type": "boolean",
      "description": "Default false."
     },
     "canWriteUsers": {
      "type": "boolean",
      "description": "Default false."
     },
     "canWriteGroups": {
      "type": "boolean",
      "description": "Default false."
     },
     "canReadAllResources": {
      "type": "boolean",
      "description": "Default false."
     },
     "capabilitiesSetAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "isVerified": {
      "type": "boolean",
      "readOnly": true,
      "description": "Set by the validation call."
     },
     "lastSync": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "lastValidation": {
      "type": [
       "object",
       "null"
      ],
      "readOnly": true,
      "description": "The result of the last validation."
     },
     "lastValidatedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "syncCount": {
      "type": "integer",
      "readOnly": true
     },
     "syncInterval": {
      "type": "integer",
      "description": "Default 24."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "ProviderSyncLog": {
    "description": "One sync of one hook. The log read is a bare array of the 10 most recent, newest first.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "providerId": {
      "type": "integer",
      "readOnly": true
     },
     "syncType": {
      "type": [
       "string",
       "null"
      ]
     },
     "status": {
      "type": [
       "string",
       "null"
      ]
     },
     "summary": {
      "type": [
       "string",
       "null"
      ]
     },
     "details": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Empty array when none."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "IdentityAlignmentLog": {
    "description": "One alignment action, as an entry of the alignment history. The route adds readable source labels to each entry.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "readOnly": true,
      "description": "A UUID. This is the id the rollback call takes."
     },
     "actionType": {
      "type": "string",
      "readOnly": true
     },
     "targetEmails": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "readOnly": true
     },
     "sourceOfTruth": {
      "type": "string",
      "readOnly": true
     },
     "priorState": {
      "type": "object",
      "readOnly": true,
      "description": "The values before the change."
     },
     "newState": {
      "type": "object",
      "readOnly": true,
      "description": "The values after it."
     },
     "status": {
      "type": "string",
      "readOnly": true,
      "description": "SUCCESS when written without another value."
     },
     "adminUser": {
      "type": "string",
      "readOnly": true,
      "description": "Who ran it."
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "AlignmentChangeRequest": {
    "description": "A bulk alignment waiting for, or past, approval. The route adds readable source labels to each entry.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "status": {
      "type": "string",
      "readOnly": true,
      "description": "pending when created."
     },
     "requestedBy": {
      "type": "string",
      "readOnly": true
     },
     "reason": {
      "type": [
       "string",
       "null"
      ]
     },
     "sourceOfTruth": {
      "type": "string"
     },
     "targets": {
      "type": "array",
      "items": {
       "type": "string"
      }
     },
     "emails": {
      "type": "array",
      "items": {
       "type": "string"
      }
     },
     "userCount": {
      "type": "integer",
      "readOnly": true
     },
     "approverEmail": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "decisionComment": {
      "type": [
       "string",
       "null"
      ]
     },
     "decidedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "appliedAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "readOnly": true
     },
     "result": {
      "type": [
       "object",
       "null"
      ],
      "readOnly": true
     },
     "error": {
      "type": [
       "string",
       "null"
      ],
      "readOnly": true
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "CloudResourceMap": {
    "description": "The stored resource map of one cloud hook. The same shape serves the three clouds.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "providerId": {
      "type": "integer",
      "description": "The hook the map belongs to."
     },
     "nodes": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "The resources found."
     },
     "edges": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "The relations between them."
     },
     "billingData": {
      "type": [
       "object",
       "null"
      ]
     },
     "lastUpdated": {
      "type": "string",
      "format": "date-time",
      "description": "When the map was last discovered."
     }
    }
   },
   "CloudSnapshot": {
    "description": "One dated copy of a resource map. The snapshot list sends id, snapshotDate and metadata only; the detail read sends every field.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "readOnly": true,
      "description": "A UUID."
     },
     "providerId": {
      "type": "integer",
      "readOnly": true
     },
     "snapshotDate": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "metadata": {
      "type": "object",
      "description": "Empty object when none."
     },
     "nodes": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Detail read only."
     },
     "edges": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Detail read only."
     },
     "billingData": {
      "type": [
       "object",
       "null"
      ],
      "description": "Detail read only."
     }
    }
   },
   "AiModelStatus": {
    "description": "Whether one local AI model is downloaded and loaded.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "model": {
      "type": "string",
      "description": "The model asked about."
     },
     "pulled": {
      "type": "boolean",
      "description": "Whether it is downloaded."
     },
     "ready": {
      "type": "boolean",
      "description": "Whether it is loaded and can answer."
     },
     "state": {
      "type": "string",
      "description": "ready | loading | pulling | engine_unreachable"
     },
     "reachable": {
      "type": "boolean",
      "description": "Whether the model engine answered."
     },
     "previousResident": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "Whether the model named by ?previous is still in memory. Null when the engine is unreachable."
     },
     "consumers": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "The features that use the local AI, each { key, label, detail, permission }."
     }
    }
   },
   "AiModelCapability": {
    "description": "Which models this server can run.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "box": {
      "type": "object",
      "description": "{ cores, ramGB, gpu, cpuParamCap }: the server as the AI service sees it."
     },
     "models": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "Each { id, label, sizeB, ramGB, fits: boolean, reason }."
     },
     "defaultModel": {
      "type": "string"
     }
    }
   },
   "Notification": {
    "description": "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.",
    "x-kind": "in a reply",
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "userId": {
      "type": "integer",
      "readOnly": true,
      "description": "Always the caller."
     },
     "type": {
      "type": "string",
      "description": "info when none was set."
     },
     "message": {
      "type": "string"
     },
     "link": {
      "type": [
       "string",
       "null"
      ],
      "description": "Where in the app the notification points."
     },
     "readAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "description": "Null while unread."
     },
     "candidateId": {
      "type": [
       "integer",
       "null"
      ]
     },
     "createdAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updatedAt": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "WorkflowReject": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "reason": {
      "type": "string",
      "description": "Not empty."
     }
    },
    "required": [
     "reason"
    ]
   },
   "WorkflowBulkDecision": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "ids": {
      "type": "array",
      "items": {
       "type": "integer"
      },
      "description": "1 to 200 workflow ids. A repeated id is processed once."
     },
     "action": {
      "type": "string",
      "enum": [
       "promote",
       "reject"
      ]
     },
     "reason": {
      "type": "string",
      "description": "Required, and not empty, when action is reject."
     }
    },
    "required": [
     "ids",
     "action"
    ]
   },
   "WorkflowClone": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "name": {
      "type": "string",
      "description": "The name of the copy, trimmed to 120 characters. Left out, the copy is named after its source with \" (copy)\" added."
     }
    }
   },
   "WorkflowRetire": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "retired": {
      "type": "boolean",
      "description": "True retires the pipeline. Anything else, or leaving it out, reinstates it."
     }
    }
   },
   "WorkflowRunRecall": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "targetNodeId": {
      "type": "string",
      "description": "The id of a human step the run has already passed."
     },
     "reason": {
      "type": "string"
     }
    },
    "required": [
     "targetNodeId"
    ]
   },
   "WorkflowNodeExecute": {
    "description": "Only these two keys are read. The action, provider and target come from the run’s stored steps.",
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "nodeId": {
      "type": "string",
      "description": "The id of the step to run."
     },
     "taskData": {
      "type": "object"
     }
    },
    "required": [
     "nodeId"
    ]
   },
   "WorkflowRunSchedule": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "startDate": {
      "type": [
       "string",
       "null"
      ],
      "format": "date",
      "description": "A real YYYY-MM-DD date. Null, or leaving the key out, clears it."
     }
    }
   },
   "CredentialReveal": {
    "x-kind": "request body",
    "type": "object",
    "properties": {
     "nodeId": {
      "type": "string",
      "description": "The id of the step that created the account."
     }
    },
    "required": [
     "nodeId"
    ]
   }
  }
 },
 "x-catalogue": {
  "operations": 869,
  "rowsMergedIntoAnotherOperation": 4,
  "typedObjects": 380,
  "schemaCoverage": {
   "pathParameters": 352,
   "pathParametersTyped": 317,
   "queryParameters": 129,
   "operationsWithQueryParameters": 31,
   "withRequestBody": 349,
   "requestBodyTyped": 236,
   "withJsonReply": 771,
   "requestBodyReferencesTypedObject": 210,
   "replyTyped": 547,
   "replyReferencesTypedObject": 269,
   "namesATypedObject": 351
  }
 }
}
