# GENERATED by sha/scripts/gen_csp_profile.py — do not edit by hand.
# A PROJECTION of docs/SHA_OPENAPI.yaml, built by the same generator from the same
# family and schema sources, filtered to the decided provider surface:
#   DR plane   shared/dr_routes.json      (csp_profile flag)
#   SHA plane  shared/csp_profile_routes.json (each entry cites its workflow step)
# Run sha/scripts/sync_csp_profile.sh.
openapi: 3.1.0
info:
  title: Sendense Hub Appliance — multi-tenant CSP/MSP provider API
  version: 1.0.0
  description: 'The provider-facing subset of the Sendense Hub Appliance API: the operations a

    CSP or MSP drives from server-side automation to run migration and disaster

    recovery for its tenants on one multi-tenant appliance.


    **This is a PROJECTION of the canonical contract, not a separate API.** Same

    host, same `/api/v1/...` paths, same schemas, same behaviour. It is generated

    from the same family and schema sources by the same generator, so a route cannot

    drift between the two. If an operation is absent here it is not provider

    workflow — it is GUI surface, a machine callback, support diagnostics or staff

    fabric — and it is deliberately not part of what a provider automates against.


    **AUTHENTICATION.** Two credential classes reach this surface: a human session

    (`sessionBearer`) and a provider service credential (`providerApiToken`). The

    provider credential is the one automation should hold: it is minted once by an

    administrator, it cannot mint a successor, its clear value exists exactly once

    in the mint response, and revoking it takes effect on its next request. A site

    API token is NOT accepted anywhere in this profile.


    **TENANCY.** Provider scope is a positive claim, never an absence: an empty site

    list means no sites, never all. Cross-tenant denial renders as **404, not 403**,

    so a denial never confirms that something exists.


    **HOW TO READ `security` ON AN OPERATION.** A provider credential authenticates

    at the appliance edge on any route and is then gated by the SAME permission map

    as a user session, so its reach is decided by the permissions it was minted with

    — which are intersected with the administrator who minted it — and by site

    scope, not by a per-route allowlist. Where an operation below lists only

    `sessionBearer`, that reflects how the canonical contract''s family declares

    itself and is NOT proof the provider credential is refused there. The

    exceptions are real and deliberate: ALL FOUR provider-credential operations —

    mint, list, read and revoke — require a human session. A service credential must

    not be able to mint a successor, and the list alone discloses the provider''s

    whole automation estate.


    **IRREVERSIBLE OPERATIONS.** Commit promotes a replica to production and

    destroys the rollback path. It requires a typed confirmation matched

    server-side against the resource''s own name plus a reason, it accepts an

    `Idempotency-Key` so a lost response can be retried safely, and a replay without

    a key answers a structured 409 naming the prior outcome rather than a generic

    error. Every DR-plane create and trigger states its retry class, because a

    timeout must never be a coin flip. Six SHA-plane operations do not — minting a

    provider credential, creating a user, setting a user''s sites, storing and

    testing a vault credential, and triggering a discovery run; the onboarding guide

    covers their replay behaviour, and the first of them is NOT safe to replay.

    '
servers:
- url: https://{sha-host}
  description: Your Sendense Hub Appliance.
  variables:
    sha-host:
      default: sha.example.com
      description: Your appliance's hostname or IP.
tags:
- name: Discovery
  description: 'Source discovery -- enumerate VMs from a backup SOURCE (VMware via an SNA tunnel, CloudStack,
    Nutanix) and manage discovery runs. Of the 16 registered `/api/v1/discovery*` routes only these 5
    are public-supported (3 GET + 2 POST); the rest are deprecated-compatibility (the legacy `discover-vms`/`add-vms`/`bulk-add`/`discover-cloudstack-vms`
    intake, superseded by `POST /discovery/unified`) or not-production-ready (the schedule create + item
    GET/PUT/DELETE, whose collection LIST is the only supported schedule route). All are served LOCALLY
    by the SHA -- `/discovery/unified` talks to the source and RE-SHAPES into local structs (NOT a verbatim
    proxy); the others read local tables. AUTH is `inventory.*`-graded and asymmetric within the prefix:
    the 3 GETs need `inventory.read`; `POST /discovery/execute` needs `inventory.discover` (a graded self-service
    slice of write, so a TENANT can rescan its OWN site); `POST /discovery/unified` needs `inventory.write`
    (STAFF/operator only -- a tenant cannot call it). The gui-service token (the GUI''s server-side proxy)
    reaches every route unscoped; an sna-service bearer is 403''d on all; an sca-service bearer HOLDS
    `inventory.read` (in its scaAllowedPermissions allowlist), so it reaches ALL THREE GET routes (`ungrouped-vms`,
    `schedules`, `executions/{id}`) UNSCOPED -- the `/discovery/executions` read-through carve-out is
    an additional (here redundant) grant, not its only reach. SITE-SCOPING is ENFORCE-MODE-ONLY and applies
    to user/site-token subjects: in off/warn/single-tenant mode, and for the gui-service token, there
    is NO filtering (the whole estate is returned). Under enforce mode: the two list GETs + ungrouped-vms
    silently DROP foreign/unclaimed rows (200, shorter list); `GET /discovery/executions/{id}` is per-object
    site-gated (foreign/unknown id -> JSON 404 `{error:"not found"}` before the handler); `POST /discovery/execute`
    gates the body `site_id` (foreign/unclaimed -> 404, and `site_id`==`"global"` requires estate-wide/staff
    access -> 403 otherwise). IMPORTANT: discovery only stamps `site_id` when the SOURCE has one, so a
    shared vCenter yields NULL-site ("unclaimed") rows as normal output -- visible only to staff under
    enforce mode, so a tenant may not see its own just-discovered VMs until they are site-stamped. Both
    writes are recovery-mode 423-capable (no `/discovery` path is exempt; the guard fires before auth).
    ERROR ENVELOPES (four shapes): middleware 401/403/423 -> JSON `{error,details,timestamp}` (ErrorResponse);
    the site-gate 404 -> JSON `{error:"not found"}` (ErrorResponse-compatible); the `enhanced_discovery`
    handler (ungrouped-vms) 500 -> JSON `{error,timestamp}` (ErrorResponse-compatible); the `unified`/`schedule`/`history`
    handlers'' 400/403/404/500 -> JSON `{status:"error",message}` (a DISTINCT shape = DiscoveryError).
    Two routes carry a oneOf of both (execute 403; executions/{id} 404 differs by mode). Success is 200
    everywhere -- notably `POST /discovery/execute` returns 200 for an ASYNC job (status `"running"` +
    an `execution_id` to poll), not 202. SECURITY (I-81, MEDIUM, no HIGH on the documented surface): no
    response carries a key/token/plaintext credential (`/discovery/unified` takes vault references only
    -- `vault_credential_id`/`cloudstack_source_id` -- and server-injects the secret; schedule/execution
    rows have no credential column). But reads echo internal estate topology (source `vcenter_host`, VM
    inventory paths, datacenter, disk storage paths/datastores, guest MACs/IPs) and, on `executions/{id}`,
    `sites_included`/`sites_included_array` which NAME OTHER TENANTS for a global run, plus raw `error_summary`
    text -- all cross-tenant/estate disclosure that is contained ONLY by enforce-mode site scope and is
    BYPASSED entirely by the gui-service AND sca-service tokens (both non-user subjects, never site-scoped),
    so an sca-service reader sees every tenant''s ungrouped-vms/schedules/executions. The EXCLUDED legacy
    intake routes additionally accept plaintext vCenter passwords in their request bodies; `/discovery/unified`
    deliberately does not (vault reference only). Field values are never shown here, only described.'
- name: Pattern assignments
  description: 'One VM''s attachment to a replication pattern, addressed directly by assignment id. The
    collection lives under its pattern (`GET /api/v1/dr/patterns/{pattern_id}/vms`); this family is how
    a single attachment is inspected, retried or cancelled. Tenancy comes from the pattern''s recovery
    site and an out-of-scope assignment renders as **404, not 403**. AUTHENTICATION: this plane accepts
    a human session (`sessionBearer`) and a provider service credential (`providerApiToken`). A site API
    token is NOT accepted — that half of the exchange is built and tested but ships DISABLED, so an `sndst_`
    token is refused at the SHA edge and never reaches the DR service; it is deliberately not advertised
    here while the shipped default denies it. The SHA authenticates you at its own edge and then EXCHANGES
    your credential for a short-lived internal identity for the DR hop: your token is never forwarded.
    The exchanged identity is audience-bound to the DR plane and issuer-bound to this appliance, lives
    about two minutes, and carries only the permissions you already held. Revoking a provider credential
    takes effect on the NEXT request, not at that identity''s expiry, because the DR service re-checks
    the originating credential''s liveness per request. Cross-tenant denial renders as **404, not 403**,
    so a denial does not confirm existence.'
- name: Failover jobs
  description: 'Individual VM failover jobs. Tenancy is inherited from the replication target, and an
    out-of-scope job renders as **404, not 403**. Reads need `dr.replication.read` and writes `dr.replication.write`.
    **Cutover is not triggered here** — a failover is started on its target (`POST /api/v1/dr/targets/{id}/failover`)
    or on a whole pattern; this family reads and repairs the resulting job. The guest-remediation callbacks
    under `/api/v1/dr/failover/` are posted by the failed-over guest OS itself, authenticated by a per-job
    token hash rather than a SHA identity, and are deliberately absent from this contract. **RETRY:**
    `rollback/retry` is the supported way to resume a rollback that failed part-way, but it is NOT safe
    to replay blindly: it calls the ordinary rollback entrypoint, it is absent from the graded-verb table
    so it needs only `dr.replication.write`, and its eligibility check admits a live or planned job still
    awaiting commit. The cleanup routes are operator remediation on a partially applied cutover. AUTHENTICATION:
    this plane accepts a human session (`sessionBearer`) and a provider service credential (`providerApiToken`).
    A site API token is NOT accepted — that half of the exchange is built and tested but ships DISABLED,
    so an `sndst_` token is refused at the SHA edge and never reaches the DR service; it is deliberately
    not advertised here while the shipped default denies it. The SHA authenticates you at its own edge
    and then EXCHANGES your credential for a short-lived internal identity for the DR hop: your token
    is never forwarded. The exchanged identity is audience-bound to the DR plane and issuer-bound to this
    appliance, lives about two minutes, and carries only the permissions you already held. Revoking a
    provider credential takes effect on the NEXT request, not at that identity''s expiry, because the
    DR service re-checks the originating credential''s liveness per request. Cross-tenant denial renders
    as **404, not 403**, so a denial does not confirm existence.'
- name: DR infrastructure lookups
  description: 'Read-only lookups against the configured source and destination platforms, used to build
    a replication pattern''s placement: the template, network, offering, zone and disk offering a pattern
    needs, and the source-side inventory a VMware source exposes. These are live lookups, not stored state,
    so a platform outage surfaces here as a 502 rather than an empty list. Requires `dr.replication.read`.
    AUTHENTICATION: this plane accepts a human session (`sessionBearer`) and a provider service credential
    (`providerApiToken`). A site API token is NOT accepted — that half of the exchange is built and tested
    but ships DISABLED, so an `sndst_` token is refused at the SHA edge and never reaches the DR service;
    it is deliberately not advertised here while the shipped default denies it. The SHA authenticates
    you at its own edge and then EXCHANGES your credential for a short-lived internal identity for the
    DR hop: your token is never forwarded. The exchanged identity is audience-bound to the DR plane and
    issuer-bound to this appliance, lives about two minutes, and carries only the permissions you already
    held. Revoking a provider credential takes effect on the NEXT request, not at that identity''s expiry,
    because the DR service re-checks the originating credential''s liveness per request. Cross-tenant
    denial renders as **404, not 403**, so a denial does not confirm existence.'
- name: DR replication metrics
  description: 'Aggregate replication trend metrics, site-filtered under multi-tenant enforcement. Requires
    `dr.replication.read`. AUTHENTICATION: this plane accepts a human session (`sessionBearer`) and a
    provider service credential (`providerApiToken`). A site API token is NOT accepted — that half of
    the exchange is built and tested but ships DISABLED, so an `sndst_` token is refused at the SHA edge
    and never reaches the DR service; it is deliberately not advertised here while the shipped default
    denies it. The SHA authenticates you at its own edge and then EXCHANGES your credential for a short-lived
    internal identity for the DR hop: your token is never forwarded. The exchanged identity is audience-bound
    to the DR plane and issuer-bound to this appliance, lives about two minutes, and carries only the
    permissions you already held. Revoking a provider credential takes effect on the NEXT request, not
    at that identity''s expiry, because the DR service re-checks the originating credential''s liveness
    per request. Cross-tenant denial renders as **404, not 403**, so a denial does not confirm existence.'
- name: Orphaned targets
  description: 'Replication targets that no pattern assignment claims — a tenant''s own stranded rows,
    typically left by a failed teardown. They are listed separately rather than under `/api/v1/dr/targets`
    because a destructive route must not depend on route-precedence to avoid being read as a target id.
    Site-filtered like every other list, with the same object gate. An orphan is what blocks a site delete,
    so provider offboarding automation reads and clears them here. AUTHENTICATION: this plane accepts
    a human session (`sessionBearer`) and a provider service credential (`providerApiToken`). A site API
    token is NOT accepted — that half of the exchange is built and tested but ships DISABLED, so an `sndst_`
    token is refused at the SHA edge and never reaches the DR service; it is deliberately not advertised
    here while the shipped default denies it. The SHA authenticates you at its own edge and then EXCHANGES
    your credential for a short-lived internal identity for the DR hop: your token is never forwarded.
    The exchanged identity is audience-bound to the DR plane and issuer-bound to this appliance, lives
    about two minutes, and carries only the permissions you already held. Revoking a provider credential
    takes effect on the NEXT request, not at that identity''s expiry, because the DR service re-checks
    the originating credential''s liveness per request. Cross-tenant denial renders as **404, not 403**,
    so a denial does not confirm existence.'
- name: Replication patterns
  description: 'Replication patterns on the disaster-recovery plane: the DR shape a tenant''s VMs attach
    to, and the unit a provider drives a whole migration from. Served by the SHA host and forwarded to
    the sendense-dr service; the routes below are the concrete operations behind that forwarding, enumerated
    from the executable DR router in `shared/dr_routes.json` and drift-guarded on both sides. **AUTH:**
    a SHA session bearer, re-validated by the DR service. Reads need `dr.replication.read` and writes
    `dr.replication.write`. The graded cutover verbs are ADDITIONAL, not alternative: the SHA edge in
    front of this plane applies the ungraded read/write pair to every `/api/v1/dr/` route, so a caller
    reaching commit, rollback or failover through the appliance needs `dr.replication.write` FIRST and
    then the graded verb — commit `dr.failover.commit` and rollback `dr.rollback` (graded by path), failover
    `dr.failover.test` or `dr.failover.live` depending on the `failover_type` in the BODY (graded in the
    handler, which is why a caller can pass the edge and still be refused). These are separately grantable
    so an operator can allow rehearsal without cutover, or cutover without commit. **SCOPE:** under multi-tenant
    enforcement, lists are filtered to the caller''s sites and a per-object miss renders as **404, not
    403**, so the API never confirms another tenant''s pattern exists. **IRREVERSIBLE OPERATIONS** (commit
    and live failover) additionally require a typed `confirm` matching the pattern''s own name — resolved
    server-side from the id — plus a non-empty `reason`. Rollback does NOT: its handler decodes only `triggered_by`,
    so the confirmation is accepted by the schema and discarded. That is a recorded gap, not the intended
    end state — see the operation itself. Test failover is deliberately exempt: it is reversible by construction,
    and a confirmation everywhere is a confirmation nowhere. **RETRY:** the only operation on this family
    that accepts an `Idempotency-Key` is the pattern commit. Reads and PATCH are safe to replay; every
    other create and trigger is not, and the operation lock is mutual exclusion rather than identity.
    Dedicated retry routes exist where replay is genuinely safe. AUTHENTICATION: this plane accepts a
    human session (`sessionBearer`) and a provider service credential (`providerApiToken`). A site API
    token is NOT accepted — that half of the exchange is built and tested but ships DISABLED, so an `sndst_`
    token is refused at the SHA edge and never reaches the DR service; it is deliberately not advertised
    here while the shipped default denies it. The SHA authenticates you at its own edge and then EXCHANGES
    your credential for a short-lived internal identity for the DR hop: your token is never forwarded.
    The exchanged identity is audience-bound to the DR plane and issuer-bound to this appliance, lives
    about two minutes, and carries only the permissions you already held. Revoking a provider credential
    takes effect on the NEXT request, not at that identity''s expiry, because the DR service re-checks
    the originating credential''s liveness per request. Cross-tenant denial renders as **404, not 403**,
    so a denial does not confirm existence.'
- name: Replication sync jobs
  description: 'Individual replication sync jobs. A sync job has no site column of its own: its tenancy
    is inherited from the replication target it replicates, and an out-of-scope job renders as **404,
    not 403**. Reads need `dr.replication.read`, writes `dr.replication.write`. Progress is polled, not
    streamed. The mover-plane callbacks under this prefix (change-ID lookup, telemetry, terminal state
    and the job-owner preflight) are machine-to-machine and are deliberately absent from this contract.
    **RETRY:** `retry` is an explicit retry route and safe to replay; the force-cleanup, reconcile and
    checkpoint-recovery routes are operator remediation. AUTHENTICATION: this plane accepts a human session
    (`sessionBearer`) and a provider service credential (`providerApiToken`). A site API token is NOT
    accepted — that half of the exchange is built and tested but ships DISABLED, so an `sndst_` token
    is refused at the SHA edge and never reaches the DR service; it is deliberately not advertised here
    while the shipped default denies it. The SHA authenticates you at its own edge and then EXCHANGES
    your credential for a short-lived internal identity for the DR hop: your token is never forwarded.
    The exchanged identity is audience-bound to the DR plane and issuer-bound to this appliance, lives
    about two minutes, and carries only the permissions you already held. Revoking a provider credential
    takes effect on the NEXT request, not at that identity''s expiry, because the DR service re-checks
    the originating credential''s liveness per request. Cross-tenant denial renders as **404, not 403**,
    so a denial does not confirm existence.'
- name: Replication targets
  description: 'Replication targets on the disaster-recovery plane: one per protected VM, from provisioning
    through sync, validation, failover and commit. Served by the SHA host and forwarded to the sendense-dr
    service; these are the concrete operations behind that forwarding, enumerated from the executable
    DR router in `shared/dr_routes.json` and drift-guarded on both sides. **AUTH:** a SHA session bearer,
    re-validated by the DR service. Reads need `dr.replication.read` and writes `dr.replication.write`.
    The graded cutover verbs are ADDITIONAL, not alternative: the SHA edge in front of this plane applies
    the ungraded read/write pair to every `/api/v1/dr/` route, so a caller reaching commit, rollback or
    failover through the appliance needs `dr.replication.write` FIRST and then the graded verb — commit
    `dr.failover.commit` and rollback `dr.rollback` (graded by path in the middleware), failover `dr.failover.test`
    or `dr.failover.live` depending on the `failover_type` in the BODY, graded in the handler, which is
    why a caller can pass the SHA edge and still be refused here. **SCOPE:** under multi-tenant enforcement,
    lists are filtered to the caller''s sites and a per-object miss renders as **404, not 403**. **LIFECYCLE
    IDENTIFIERS:** before commit, `controller_vm_id` names the Sendense DR controller — infrastructure,
    not the customer''s production VM. At commit that same VM is promoted and renamed, and the `promoted_*`
    fields become the authoritative production identity. They are NOT populated during provisioning, failover,
    or while pending commit. **RETRY:** the only operation on this family that accepts an `Idempotency-Key`
    is the target commit. Reads, PATCH and PUT are safe to replay; every other trigger is not. The per-target
    operation lock rejects a CONCURRENT second request and releases on completion, so it is mutual exclusion
    rather than identity. Dedicated retry routes exist where replay is genuinely safe. **Direct target
    creation is retired**: `POST /api/v1/dr/targets` answers 410 and names its replacement, attaching
    the VM to a pattern. AUTHENTICATION: this plane accepts a human session (`sessionBearer`) and a provider
    service credential (`providerApiToken`). A site API token is NOT accepted — that half of the exchange
    is built and tested but ships DISABLED, so an `sndst_` token is refused at the SHA edge and never
    reaches the DR service; it is deliberately not advertised here while the shipped default denies it.
    The SHA authenticates you at its own edge and then EXCHANGES your credential for a short-lived internal
    identity for the DR hop: your token is never forwarded. The exchanged identity is audience-bound to
    the DR plane and issuer-bound to this appliance, lives about two minutes, and carries only the permissions
    you already held. Revoking a provider credential takes effect on the NEXT request, not at that identity''s
    expiry, because the DR service re-checks the originating credential''s liveness per request. Cross-tenant
    denial renders as **404, not 403**, so a denial does not confirm existence.'
- name: DR replication posture
  description: 'Replication posture across the caller''s VMs, site-filtered under multi-tenant enforcement.
    Requires `dr.replication.read`. AUTHENTICATION: this plane accepts a human session (`sessionBearer`)
    and a provider service credential (`providerApiToken`). A site API token is NOT accepted — that half
    of the exchange is built and tested but ships DISABLED, so an `sndst_` token is refused at the SHA
    edge and never reaches the DR service; it is deliberately not advertised here while the shipped default
    denies it. The SHA authenticates you at its own edge and then EXCHANGES your credential for a short-lived
    internal identity for the DR hop: your token is never forwarded. The exchanged identity is audience-bound
    to the DR plane and issuer-bound to this appliance, lives about two minutes, and carries only the
    permissions you already held. Revoking a provider credential takes effect on the NEXT request, not
    at that identity''s expiry, because the DR service re-checks the originating credential''s liveness
    per request. Cross-tenant denial renders as **404, not 403**, so a denial does not confirm existence.'
- name: Licensing
  description: 'Licence administration for the appliance: read status/entitlements, activate, force a
    re-verify, inspect and correct workload licence assignments (list/events/release/reset-mig/disarm-hold),
    and manage capacity reconciliation.


    **Provider-wide, and staff-only.** Every read here spans the WHOLE appliance — its licensed capacity,
    its consumption, its per-tenant licence positions and every individual 90-day hold. There is no site
    column to filter any of it by, so all ten routes are registered staff-only: on a multi-tenant appliance
    (site scope enforced) a site-scoped caller receives `404` even when it holds `settings.read`, because
    a credential''s permission set and its site scope are different axes and an administrator minting
    an `sndst_` token may legitimately grant it a staff permission. On a single-tenant appliance the site
    gate is inert by design and behaviour is unchanged. Accepted credentials are therefore a human session
    (`sessionBearer`) and a provider service credential (`providerApiToken`) — never a site API token.


    Reads require `settings.read`; writes require `settings.write`. The FOUR entitlement-mutating POSTs
    (release, reset-mig, disarm-hold, reconciliation/release) additionally require a HUMAN operator session
    and refuse a provider service credential with 403 even when it holds `settings.write` — the immutable
    ledger''s `actor` column must name a person. They are the only operations in this family a read-scoped
    or service credential can never perform. `activate` and `verify-now` are ordinary `settings.write`
    operations. No activation token or licence secret is ever returned.


    SHARP EDGES vary by route and are documented per operation: there is NO 409 anywhere (conflicts/illegal
    transitions are typed 403 refusals, and an already-terminal state is an idempotent 200); activate
    collapses all failures to a 400; licence-server unreachability degrades to a 200 with `central_verification.status=UNREACHABLE`
    (not a 503) and never leaks the upstream error text; the real 503s are missing DB tables or an unavailable
    audit sink on the critical writes; all POSTs are 423-locked in recovery mode; and the `reconciliation`
    field on /status is a DIFFERENT shape from GET /reconciliation.'
- name: Provider API credentials
  description: 'Appliance-wide automation credentials (prefix `sndpv_`) for the provider operating this
    multi-tenant appliance. **This is a server-side credential and must never be issued to an end tenant,
    placed in a browser, shipped in a downloadable client bundle, or embedded in public JavaScript** —
    it reaches every tenant on the appliance. It is distinct from the tenant credential at `/api/v1/api-tokens`
    (prefix `sndst_`), which is bounded to a tenant''s sites. The clear value is shown once at mint and
    only a hash is stored; it cannot be retrieved again. Provider reach comes from an explicit `scope:
    "provider"` claim on the stored credential, never from an empty site list — an empty site list means
    no sites, for every credential class. **All four operations require `settings.write`** (reading the
    list discloses the provider''s whole automation estate, so it is not a lesser grant) **and a human
    provider-administrator session**. A provider service credential is refused on all four with 403 —
    not only on mint. Mint is the obvious case, since a credential that could issue a successor would
    survive revoking the original; revoke matters as much in the other direction, because a leaked credential
    able to revoke every provider credential could kill the incident response that would contain it. The
    routes are additionally registered staff-only, so a site-bounded caller cannot reach them at all.
    Mint and revoke are recorded fail-closed in the audit trail — the attempt is written before the handler
    runs and the operation is refused if that write fails, so an appliance-wide credential is never issued
    or killed unlogged. Revocation takes effect on the very next request.'
- name: Sites
  description: 'Sites are the tenancy and locality structure an appliance is organised by. Reads require
    `settings.read`. The alternative read permissions (`inventory.read` for list and detail, and `dr.replication.read`
    for list) only apply when site scoping is ENFORCED; with scoping off or warn (the single-tenant default)
    only `settings.read` grants read. Writes always require `settings.write`. Note that the list, detail
    and create responses are deliberately DIFFERENT shapes — see each operation. NOTE: this family''s
    handler errors (400/404/409/500) return a PLAIN-TEXT body, not the JSON error envelope other families
    use — a convergence item tracked as I-25. Middleware 401/403 are still JSON.'
- name: Tenants
  description: 'Provider-side tenant management: provision a tenant''s sites and logins in one reviewed
    change, list them, and edit declaratively. Staff-only (settings.read / settings.write); a tenant cannot
    reach its own management surface.'
- name: Users
  description: 'Manage appliance users, their roles and their site assignments. Reads require `users.read`;
    writes require `users.write`. This family reports failures as `400` — a missing user, a duplicate
    email and a last-admin block are all 400, never 404 or 409. Roles are referenced by name. Every route
    in this family is STAFF-ONLY under enforced multi-tenancy: a site-scoped subject (a tenant login or
    a site API token) is answered 404 by the route gate, so a site API token is not a credential this
    family accepts; a human session and the provider service credential are.'
- name: Vault
  description: 'The credential vault: store and manage connection credentials (VMware, CloudStack, Nutanix,
    SQL Server, S3, Azure, NFS/CIFS, SMTP, LDAP/AD, remote-host SSH), list the per-type field schemas,
    and test connectivity. Reads require `settings.read`; writes require `settings.write`; the family
    is staff-only in practice. Stored secrets are MASKED to `********` in credential responses by default;
    an admin can retrieve decrypted values via GET `?unmask=true`. All writes (create/update/delete and
    both test endpoints) are 423-locked during control-plane recovery mode. SHARP EDGES vary by route
    and are documented per operation: create returns HTTP 200 (not 201); handler errors use a `{success:false,
    error:{code,message}}` envelope while auth/recovery errors use `{error,details,timestamp}`; list pagination
    is not applied (`page`/`limit` are cosmetic); unknown-id handling is uneven (GET/PUT/DELETE/{id}/test
    404, but audit returns 200); a rename to a duplicate name is a 500 (create is 409); and `sql_server`
    is currently rejected on create despite being an advertised type.'
- name: VM contexts
  description: 'VM inventory -- the appliance''s discovered/managed virtual machines (`vm_contexts`),
    one record per VM across all source platforms (VMware, CloudStack, Hyper-V, Nutanix, ...). These 31
    routes read the inventory (list/detail/disks/NICs/jobs/groups/stats/chains/validation evidence), mutate
    per-VM settings (app-type, AD credential, storage backend, guest address, failover OS type), manage
    lifecycle (manage/unmanage/bulk-manage, remove), assign site/group ownership, and run refresh/validation
    actions. All are served LOCALLY by the SHA. AUTH is a SINGLE prefix rule with no grading: every GET
    needs `inventory.read`; every write (POST/PUT/PATCH/DELETE) needs `inventory.write`. CRITICAL: on
    a stock appliance NO tenant replication tier holds `inventory.write`, so only the 15 GETs are tenant-reachable
    and all 16 mutations are staff/admin/service-only. All 16 mutations are recovery-mode 423-capable
    (this prefix is not exempt; the guard fires before auth). SITE-SCOPING (enforce-mode only, for user/site-token
    subjects): list reads silently drop foreign/unclaimed rows; direct object reads are per-object gated
    -- a foreign OR unknown id collapses to a JSON existence-hiding 404 `{error:"not found"}` (no existence
    oracle; NULL-site = unclaimed = staff-only). The TWO site-assignment writes (bulk-site, site) exist
    precisely to give unclaimed VMs an owner (TEN-002) and NEVER un-assign. LATENT ASYMMETRY (I-82): the
    manage / unmanage / bulk-manage / group / validation-sessions / refresh-cloudstack write handlers
    have NO in-handler site gate -- harmless today because `inventory.write` is tenant-unreachable, but
    a custom role granted that permission would act cross-tenant on exactly those six; the VMContext PATCH/DELETE
    handlers stay gated. ERROR ENVELOPES: SIX shapes coexist -- `{error,details,timestamp}` (middleware/recovery-423),
    `{error,timestamp}` (VMContext/validation-session/cloudstack/enhanced-discovery handlers), `{error}`
    / `{error:"not found"}` (site gate + site-assignment), `{error,details}` (chains) -- ALL ErrorResponse-compatible;
    `{status:"error",message}` (VMContextStatusError -- RefreshVM, the five PATCH routes, DELETE, GetBackupTriggers,
    RemovalPreview); and PLAIN-TEXT (`http.Error` -- the manage/unmanage/bulk-manage handlers and the
    GetJobConflict 400). Where one status returns two shapes by caller class it is modeled as a oneOf
    (VMContextNotFound). SUCCESS is 200 everywhere (no 201/202/204; DELETE returns a JSON body). PATH
    VARIABLES differ by route: most use `{context_id}`; `GET /{vm_name}` keys on the human name (legacy
    shape); `/by-id/{context_id}` is a literal-segment lookup; `PUT /{context_id}/group` reads the variable
    as `id`; the screenshot route anchors on `{evidence_id}`. ARRAY NULLABILITY is not uniform (verified
    against this GORM version): `.Find`-backed lists and make()''d lists are always `[]` (vm_contexts,
    disks, nics, groups, triggers, assigned, ungrouped vms, changes_detected), while `recent-jobs.jobs`
    (nil slice + append), `chains` on the unfiltered path (`.Raw().Scan`), and validation-evidence `evidence`/`attestations`
    (`.Scan`) serialize as `null` when empty. SECURITY: no plaintext credential or private key is echoed
    by any read -- credentials appear only as opaque vault/id REFERENCES, and the AD-credential PATCH
    validates without decrypting. Two MEDIUM disclosures are documented on the fields that carry them:
    `vnc_ws_url` (a live single-use VNC session token, on validation-evidence.active_session and validation-sessions)
    and `evidence[].screenshot_path` (an absolute appliance filesystem path). Reads also echo internal
    estate topology (vCenter host, inventory/resource-pool paths, datacenter, datastores) and guest MAC/IP
    data -- contained only by enforce-mode site scope for scoped callers. Field values are never shown
    here, only described.'
x-tagGroups:
- name: Getting started
  tags:
  - Provider API credentials
- name: Multi-tenancy
  tags:
  - Sites
  - Tenants
- name: Access management
  tags:
  - Users
- name: Protection
  tags:
  - Discovery
  - VM contexts
- name: Recovery
  tags:
  - Pattern assignments
  - Failover jobs
  - DR infrastructure lookups
  - DR replication metrics
  - Orphaned targets
  - Replication patterns
  - Replication sync jobs
  - Replication targets
  - DR replication posture
- name: Operations
  tags:
  - Licensing
  - Vault
security:
- sessionBearer: []
- providerApiToken: []
components:
  securitySchemes:
    sessionBearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: 'Human session token from POST /api/v1/auth/login (body: {email, password}). Short-lived
        and refreshable at /api/v1/auth/refresh; the lifetime is operator-configurable (auth_access_ttl_minutes,
        default 15 minutes) so always read expires_at rather than assuming a fixed TTL. Use this for interactive
        and administrative access.'
    providerApiToken:
      type: http
      scheme: bearer
      description: Long-lived, appliance-wide service credential (prefix `sndpv_`) minted at POST /api/v1/provider-tokens
        by a human provider administrator. Its reach comes from an explicit provider-scope claim, never
        from an empty site list, and it is still bounded by the permission set it was minted with. **Server-side
        only** — it reaches every tenant on the appliance, so it must never be issued to a tenant, placed
        in a browser or shipped in a client bundle. Declared only on operations a provider credential
        can actually reach; it cannot mint or revoke another provider credential, and it cannot unmask
        a stored vault secret.
  schemas:
    AssignmentsListResponse:
      type: object
      properties:
        assignments:
          type: array
          items:
            $ref: '#/components/schemas/LicenceAssignment'
        count:
          type: integer
          description: Rows in THIS response. Equals `total` when no window is applied.
        total:
          type: integer
          description: Rows matching the filters, ignoring `limit`/`offset`. Page until `offset + count
            >= total`.
      required:
      - assignments
      - count
      - total
      description: '`assignments` is always an array, empty rather than null. NO tenant or site attribution
        field exists on this response: the assignment row''s own `tenant_id` column is recorded in its
        schema as display/audit grouping and never an authorization input, so it is deliberately not published
        here as though it were authoritative. Join on `vm_context_id` (nullable, and it may change across
        DR stages) or on the durable `source_workload_key` / `workload_lineage_id`.'
    Credential:
      type: object
      properties:
        id:
          type: string
        credential_type:
          type: string
        credential_name:
          type: string
        description:
          type: string
        credential_data:
          type: object
          additionalProperties: true
          description: The credential's fields (shape varies by type). In the default response, recognized
            sensitive fields (password/secret*/api_key/account_key/connection_string/bind_password) are
            MASKED to `********`. An admin can retrieve the decrypted values via `?unmask=true` (GET only).
        metadata:
          type: object
          additionalProperties: true
        default_storage_backend:
          type: string
        scope:
          type: string
        scope_id:
          type: string
        is_active:
          type: boolean
        is_default:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      description: A stored credential (masked). `id`/`created_at`/`updated_at`/`is_active` are server-computed.
      required:
      - id
      - credential_type
      - credential_name
      - credential_data
      - scope
      - is_active
      - is_default
      - created_at
      - updated_at
    CredentialInput:
      type: object
      properties:
        credential_type:
          type: string
          description: 'One of: vmware, cloudstack, active_directory, sql_server, s3, azure_blob, nfs,
            cifs, smtp, ldap, remote_host_ssh, nutanix.'
        credential_name:
          type: string
        credential_data:
          type: object
          additionalProperties: true
          description: The credential's fields IN PLAINTEXT (e.g. password/secret_key). Required keys
            depend on `credential_type`.
        description:
          type: string
        metadata:
          type: object
          additionalProperties: true
        default_storage_backend:
          type: string
        scope:
          type: string
          description: '`global` (default), `site`, or `appliance`. `scope_id` must be empty for global
            and set for site/appliance.'
        scope_id:
          type: string
        is_default:
          type: boolean
        created_by:
          type: string
          description: Ignored — the server records the caller.
      description: 'Store a new credential. NOTE: `sql_server` (and any type without a validation branch)
        is currently rejected with a 400 even though it is a valid advertised type.'
      required:
      - credential_type
      - credential_name
      - credential_data
    DRAssignmentActionResponse:
      type: object
      properties:
        message:
          type: string
        assignment_id:
          type: string
      required:
      - message
      - assignment_id
    DRBlockedCleanupTarget:
      type: object
      required:
      - target_id
      - status
      properties:
        target_id:
          type: string
        source_vm_name:
          type: string
        status:
          type: string
          description: 'The target’s LIFECYCLE status, which is not what decided this entry. A released
            target is usually in an ordinary replication state — the cutover safety state below is the
            authority. Read `cutover_safety_state` for the disposition and `remedy` for what to do. The
            literal `unknown` appears here when the target ROW itself could not be read: `cutover_safety_state`
            is then absent, `last_error` says nothing was done to the target, and `remedy` is to replay
            the request once the appliance database answers. That is a different entry from one whose
            row read fine but whose `cutover_safety_state` this appliance does not recognise — that one
            keeps its real `status`, reports the unrecognised value, and asks for an upgrade or a review
            before any cleanup.'
        cutover_safety_state:
          type: string
          enum:
          - ordinary
          - cutover_protected
          - promoted
          - reconciliation_required
          - unknown
          description: Why this target was released or blocked, as a machine-readable value rather than
            only as prose inside `remedy`. `promoted` is a completed migration whose VM is kept; `reconciliation_required`
            means the destination resources may still exist and no Sendense path will delete them; `cutover_protected`
            is blocked rather than released, and finishing the cutover is its exit.
        last_error:
          type: string
        retry_endpoint:
          type: string
          description: The exact call that unsticks a `delete_failed` target, e.g. `POST /api/v1/dr/targets/{id}/destroy/retry`.
            Absent for a target a failover touched — a destroy retry is the wrong act there; see `remedy`.
        remedy:
          type: string
          description: 'Present for a target in a cutover state: what a person must do before the pattern
            can release it (roll a rehearsal or an uncommitted failover back, or commit it); under `released_targets`,
            where the committed record now lives and how to remove it.'
    DRCommitReadinessResponse:
      type: object
      additionalProperties: true
      properties:
        can_commit:
          type: boolean
        reason:
          type: string
      required:
      - can_commit
    DRCommitResponse:
      type: object
      additionalProperties: true
      properties:
        target_id:
          type: string
        job_id:
          type: string
        promoted_vm_id:
          type: string
          description: The destination VM that became production. Equal to the controller VM id by construction.
        promoted_vm_name:
          type: string
        promoted_cloudstack_instance_name:
          type: string
          description: Best effort; may be empty on an otherwise successful commit.
        promoted_rename_status:
          type: string
        rollback_possible:
          type: boolean
          description: False after a successful commit — the rollback path is discarded.
        cleanup_incomplete:
          type: boolean
          description: True when the commit succeeded but some cleanup did not; the promotion still stands.
      required:
      - target_id
      - job_id
    DRConfirmationRefusal:
      type: object
      description: A refused typed confirmation on an irreversible operation. `confirm_with` names exactly
        what to send as `confirm`; the match is server-side and case-insensitive.
      properties:
        error:
          type: string
        code:
          type: string
          enum:
          - CONFIRMATION_REQUIRED
        confirm_with:
          type: string
          description: The value `confirm` must carry — the resource's own authoritative name, or its
            identifier when the name cannot be resolved.
      required:
      - error
      - code
      - confirm_with
    DRDisk:
      type: object
      properties:
        id:
          type: string
          description: Disk identifier.
        replication_target_id:
          type: string
        disk_type:
          type: string
          description: '`replica` is a copy of a source disk; `controller` belongs to the Sendense DR
            controller VM itself.'
        source_disk_index:
          type: integer
          description: Position on the source VM. Do not assume index 0 is the boot disk — read `root_disk_index`
            on the target read instead.
        source_disk_id:
          type: string
        source_unit_number:
          type: integer
        source_size_bytes:
          type: integer
        volume_id:
          type: string
          description: The replica volume in the destination cloud.
        volume_name:
          type: string
        size_bytes:
          type: integer
        destination_disk_id:
          type: string
          description: The destination disk identifier once attached.
        destination_disk_path:
          type: string
        destination_unit_number:
          type: integer
        controller_device_path:
          type: string
        current_device_id:
          type:
          - integer
          - 'null'
        original_device_id:
          type: integer
        qemu_nbd_port:
          type:
          - integer
          - 'null'
        qemu_nbd_pid:
          type:
          - integer
          - 'null'
        last_change_id:
          type: string
          description: The incremental-sync anchor. Losing it forces the next sync to be a full.
        last_cloudstack_bookmark:
          type: string
        last_sync_generation_id:
          type: string
        last_change_id_confirmed_at:
          type:
          - string
          - 'null'
          format: date-time
        last_change_id_intent_id:
          type: string
        snapshot_id:
          type: string
        snapshot_created_at:
          type:
          - string
          - 'null'
          format: date-time
        qcow2_valid:
          type:
          - boolean
          - 'null'
        quick_check_at:
          type:
          - string
          - 'null'
          format: date-time
        boot_validation_status:
          type: string
          description: Result of boot validation for this replica.
        boot_validation_details:
          type: string
        boot_validation_at:
          type:
          - string
          - 'null'
          format: date-time
        health_error:
          type: string
        validation_stage:
          type: string
        validation_in_progress:
          type: boolean
        health_error_acknowledged_at:
          type:
          - string
          - 'null'
          format: date-time
        health_error_acknowledged_by:
          type: string
        status:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      description: One disk of a replication target.
    DRDiskCreateRequest:
      type: object
      properties:
        source_disk_index:
          type: integer
        size_bytes:
          type: integer
        disk_offering_id:
          type: string
      required:
      - size_bytes
    DRDiskCreateResponse:
      type: object
      properties:
        message:
          type: string
        disk:
          $ref: '#/components/schemas/DRDisk'
      required:
      - message
      - disk
    DRDiskListResponse:
      type: object
      properties:
        disks:
          type: array
          items:
            $ref: '#/components/schemas/DRDisk'
        total:
          type: integer
      required:
      - disks
      - total
    DRFailoverCancelResponse:
      type: object
      additionalProperties: true
      properties:
        job_id:
          type: string
        cancelled:
          type: boolean
      description: What the cancellation was able to stop.
    DRFailoverJob:
      type: object
      properties:
        id:
          type: string
          description: Failover job identifier, returned by the failover trigger.
        replication_target_id:
          type: string
        failover_type:
          type: string
          description: '`test`, `planned` or `unplanned`.'
        status:
          type: string
          description: Job lifecycle state. `pending_commit` means the cutover completed and is awaiting
            the irreversible commit.
        current_step:
          type: string
        progress_percent:
          type: number
        error_message:
          type: string
        failed_at_step:
          type: string
        guest_remediation_status:
          type: string
        guest_remediation_summary:
          type: string
        guest_remediation_reported_at:
          type:
          - string
          - 'null'
          format: date-time
        started_at:
          type:
          - string
          - 'null'
          format: date-time
        completed_at:
          type:
          - string
          - 'null'
          format: date-time
        triggered_by:
          type: string
          description: Display metadata only. The audit actor comes from the authenticated identity.
        pattern_failover_job_id:
          type:
          - string
          - 'null'
          description: Set when this VM's failover is part of a pattern-wide cutover.
        execution_order:
          type: integer
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      description: One VM's failover job.
    DRFailoverJobListResponse:
      type: object
      properties:
        jobs:
          type: array
          items:
            $ref: '#/components/schemas/DRFailoverJob'
        total:
          type: integer
      required:
      - jobs
      - total
    DRFailoverJournalResponse:
      type: object
      additionalProperties: true
      properties:
        job_id:
          type: string
        entries:
          type: array
          items:
            type: object
            additionalProperties: true
          description: 'Ordered journal entries: the step, its outcome and any compensation.'
      description: The per-step audit trail for a failover job.
    DRFailoverReadinessResponse:
      type: object
      properties:
        ready:
          type: boolean
        reason:
          type: string
      required:
      - ready
      - reason
    DRFailoverRequest:
      type: object
      properties:
        failover_type:
          type: string
          enum:
          - test
          - planned
          - unplanned
          description: 'Selects the required permission: test -> dr.failover.test; planned and unplanned
            -> dr.failover.live.'
        triggered_by:
          type: string
          description: Display metadata only.
        confirm:
          type: string
          description: Typed confirmation matching the target's source_vm_name. Required for a live or
            planned cutover under enforcement.
        reason:
          type: string
          description: Required alongside `confirm`.
        network_mappings:
          type: array
          items:
            type: object
            additionalProperties: true
        skip_final_sync:
          type: boolean
        test_network_id:
          type: string
          description: Isolated network for a test failover.
        windows_guest_network_mode:
          type: string
          enum:
          - restore_source
          - preserve_current
          - manual_static
        windows_guest_callback_mode:
          type: string
          enum:
          - final_best_effort
          - final_required
          - disabled
        windows_guest_manual_network:
          type: object
          additionalProperties: true
          description: Required when windows_guest_network_mode is manual_static.
        validate_boot:
          type: boolean
      required:
      - failover_type
    DRFailoverTriggerResponse:
      type: object
      properties:
        message:
          type: string
        job:
          $ref: '#/components/schemas/DRFailoverJob'
      required:
      - message
      - job
    DRInfraListResponse:
      type: object
      properties:
        success:
          type: boolean
        data:
          type: array
          items:
            type: object
            additionalProperties: true
            description: One platform object. Its fields are the platform's own.
      required:
      - success
      - data
    DRIrreversibleRequest:
      type: object
      description: The typed-confirmation body for an irreversible operation. `confirm` is matched SERVER-SIDE
        against the resource's own authoritative name (its identifier when the name cannot be resolved),
        so posting back whatever was sent does not satisfy it; `reason` must be non-empty. Both are REQUIRED
        under multi-tenant enforcement — the posture the provider profile is written for — and a missing
        or mismatched value is refused with 400 `CONFIRMATION_REQUIRED` before anything runs. Outside
        enforcement they are accepted and not enforced.
      properties:
        confirm:
          type: string
          description: The resource's own name, typed by the operator.
        reason:
          type: string
          description: Non-empty. Recorded with the operation.
      required:
      - confirm
      - reason
    DROrphanRemovalConfirmation:
      type: object
      required:
      - key
      - reason
      properties:
        key:
          type: string
          description: The body field to send as `true`.
        reason:
          type: string
          description: What confirming it discards or leaves behind.
    DROrphanRemovalConflict:
      type: object
      required:
      - error
      properties:
        error:
          type: string
        confirmations_required:
          type: array
          items:
            $ref: '#/components/schemas/DROrphanRemovalConfirmation'
        plan_changed:
          type: boolean
          description: True when the supplied plan token is missing or stale.
        target:
          $ref: '#/components/schemas/DROrphanRemovalPlan'
          description: The current plan, so the caller can confirm against what is true now.
        target_id:
          type: string
          description: Present on the stopped-being-an-orphan refusal.
    DROrphanRemovalPlan:
      type: object
      properties:
        target_id:
          type: string
        vm_context_id:
          type: string
        source_vm_name:
          type: string
        site_id:
          type: string
        status:
          type: string
        vm_context_missing:
          type: boolean
        controller_vm_id:
          type: string
        promoted_vm_id:
          type: string
        cutover_safety_state:
          type: string
          enum:
          - ordinary
          - cutover_protected
          - promoted
          - reconciliation_required
          - unknown
          description: 'Whether an ordinary Sendense cleanup path may delete this target’s destination
            VM and volumes. It is what makes this inventory the RECONCILIATION list as well as the orphan
            list: for `reconciliation_required` and `promoted` the destination resources may still exist
            and no Sendense path will ever delete them, so the removal here is bookkeeping and NOT cleanup.
            `cutover_protected` and `reconciliation_required` ask you to acknowledge that by name, through
            the `discard_provider_resources` confirmation. `promoted` deliberately does NOT: that VM is
            the tenant’s production workload and is KEPT, so a confirmation inviting you to discard it
            would be the wrong sentence — the plan discloses the retained VM instead. An empty `confirmations_required`
            therefore does not mean "nothing to lose" for a non-ordinary state. `unknown` means this appliance
            does not recognise the recorded value.'
        cutover_safety_reason:
          type: string
          description: Why the cutover safety state was last written. Diagnostic.
        failover_jobs:
          type: integer
        sync_jobs:
          type: integer
        failover_history_unknown:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        summary:
          type: string
        pure_garbage:
          type: boolean
          description: 'Nothing to lose: no VM context, no failover history, no controller VM. Removes
            with an empty body.'
        blockers:
          type: array
          items:
            type: string
          description: Why this route cannot remove the target (e.g. a live pattern still claims it).
            Present when `removable` is false.
        disclosures:
          type: array
          items:
            type: string
        destroys:
          type: array
          items:
            type: string
          description: What the removal deletes, in words.
        confirmations_required:
          type: array
          items:
            $ref: '#/components/schemas/DROrphanRemovalConfirmation'
        recommended_action:
          type: string
        removable:
          type: boolean
        plan_token:
          type: string
          description: Quote this in the removal body. Changes whenever what the removal would destroy
            changes.
      description: The removal plan for one orphaned target — the shape `getDROrphanedTarget` answers
        and the `target` every refusal and the success response carry.
      required:
      - target_id
      - vm_context_id
      - status
      - vm_context_missing
      - failover_jobs
      - sync_jobs
      - created_at
      - updated_at
      - summary
      - pure_garbage
      - removable
      - plan_token
      - cutover_safety_state
    DROrphanRemovalRequest:
      type: object
      properties:
        plan_token:
          type: string
          description: The `plan_token` returned by `getDROrphanedTarget`. Required whenever the plan
            names any confirmation; refused 409 `plan_changed` when missing or stale.
        discard_failover_history:
          type: boolean
          description: Acknowledge that the target's failover jobs and their state journal are permanently
            deleted. Required when the plan names it.
        discard_provider_resources:
          type: boolean
          description: Acknowledge that the controller VM and its replica volumes are NOT deleted by this
            call and remain in the destination cloud. Required when the plan names it.
      additionalProperties:
        type: boolean
      description: 'Flat: `plan_token` plus one boolean per confirmation key the plan names. Additional
        boolean keys are accepted so a newer plan''s confirmations can be satisfied; non-boolean values
        under other keys are ignored.'
    DROrphanRemovalResponse:
      type: object
      properties:
        message:
          type: string
        target_id:
          type: string
        discarded:
          type: object
          additionalProperties:
            type: integer
          description: Row counts deleted per record kind.
        target:
          $ref: '#/components/schemas/DROrphanRemovalPlan'
      required:
      - message
      - target_id
      - discarded
      - target
    DROrphanedTargetListResponse:
      type: object
      properties:
        orphaned_targets:
          type: array
          items:
            $ref: '#/components/schemas/DROrphanRemovalPlan'
        total:
          type: integer
      required:
      - orphaned_targets
      - total
    DRPattern:
      type: object
      properties:
        pattern_id:
          type: string
          description: Stable identifier. Available from the create response onward.
        name:
          type: string
          description: Unique per site, not globally — one tenant's pattern name never blocks another's.
        description:
          type:
          - string
          - 'null'
        enabled:
          type: boolean
          description: Honoured on create. A pattern may be pre-created dormant and switched on when the
            tenant goes live.
        sync_interval_minutes:
          type: integer
        allow_manual_full_sync:
          type: boolean
        replication_route_policy:
          type: string
        site_id:
          type:
          - string
          - 'null'
          description: 'The recovery site that owns this pattern. This is the tenancy anchor: site scope
            is enforced against it.'
        destination_hypervisor:
          type: string
        destination_config:
          type: object
          additionalProperties: true
        destination_vault_credential_id:
          type:
          - string
          - 'null'
          description: Pin this whenever the site holds more than one destination credential; unpinned
            falls back to a site-default ranking.
        template_id:
          type: string
        network_id:
          type: string
        replication_network_id:
          type:
          - string
          - 'null'
        disk_offering_id:
          type:
          - string
          - 'null'
        service_offering_id:
          type:
          - string
          - 'null'
        zone_id:
          type:
          - string
          - 'null'
        default_cpu_number:
          type:
          - integer
          - 'null'
        default_memory_mb:
          type:
          - integer
          - 'null'
        keypair:
          type:
          - string
          - 'null'
        max_concurrent_syncs:
          type:
          - integer
          - 'null'
        max_concurrent_provisions:
          type:
          - integer
          - 'null'
        total_vms:
          type: integer
          description: Assigned VMs. Zero is a supported, stable state for a pre-created pattern.
        active_vms:
          type: integer
          description: Assigned VMs whose replication target is materialised.
        last_sync_at:
          type:
          - string
          - 'null'
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        assignments:
          type: array
          items:
            $ref: '#/components/schemas/DRPatternAssignment'
          description: Expanded VM assignments, on reads that include them.
        delete_requested_at:
          type: string
          format: date-time
          description: Present only while a delete has been accepted and its controller cleanup is running;
            the pattern is disabled and will answer 404 once the last target it owns is gone. Absent otherwise
            — this is how a poller tells `deleting` from `disabled`.
      description: 'A replication pattern: the DR shape a tenant''s VMs are attached to. Creating one
        with no VMs is a supported, stable state — a zero-VM pattern owns no replication targets, so the
        sync scheduler, the manual-sync dispatcher and the failover orchestrator all no-op on it, and
        it consumes no protected-VM licence admission. Licence admission and source-site authorisation
        run at VM ATTACH, not here.'
    DRPatternActiveFailoverResponse:
      type: object
      properties:
        active:
          type: boolean
        job:
          oneOf:
          - $ref: '#/components/schemas/DRPatternFailoverJob'
          - type: 'null'
          description: Null when `active` is false.
      required:
      - active
    DRPatternAddVMsRequest:
      type: object
      description: Supply either `vm_context_ids` or `vms`.
      properties:
        vm_context_ids:
          type: array
          items:
            type: string
        vms:
          type: array
          items:
            type: object
            additionalProperties: true
          description: Per-VM attachment records, for overriding the pattern default network.
    DRPatternAddVMsResponse:
      type: object
      properties:
        assignments:
          type: array
          items:
            $ref: '#/components/schemas/DRPatternAssignment'
        message:
          type: string
      required:
      - assignments
      - message
    DRPatternAssignment:
      type: object
      properties:
        id:
          type: string
          description: Assignment identifier. Available from the attach response onward.
        pattern_id:
          type: string
        vm_context_id:
          type: string
          description: The source VM this assignment attaches.
        network_id:
          type:
          - string
          - 'null'
        replication_target_id:
          type:
          - string
          - 'null'
          description: The materialised replication target, once provisioning has created one. Null until
            then.
        status:
          type: string
          description: Assignment lifecycle state.
        current_step:
          type:
          - string
          - 'null'
        error_message:
          type:
          - string
          - 'null'
        provision_started_at:
          type:
          - string
          - 'null'
          format: date-time
        provision_completed_at:
          type:
          - string
          - 'null'
          format: date-time
        last_sync_at:
          type:
          - string
          - 'null'
          format: date-time
        last_sync_status:
          type:
          - string
          - 'null'
        sync_enabled:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        vm_name:
          type: string
          description: Source VM name, resolved for display. Omitted when it cannot be resolved.
        source_platform:
          type: string
          description: Source platform, resolved for display.
      description: One VM's attachment to a replication pattern.
    DRPatternAssignmentListResponse:
      type: object
      properties:
        assignments:
          type: array
          items:
            $ref: '#/components/schemas/DRPatternAssignment'
        total:
          type: integer
        by_status:
          type: object
          additionalProperties:
            type: integer
      required:
      - assignments
      - total
    DRPatternAssignmentUpdateRequest:
      type: object
      properties:
        network_id:
          type:
          - string
          - 'null'
          description: Required, explicitly. Send null to clear the per-VM override.
        sync_enabled:
          type: boolean
    DRPatternBulkRemoveRequest:
      type: object
      properties:
        vm_context_ids:
          type: array
          items:
            type: string
      required:
      - vm_context_ids
    DRPatternBulkRemoveResponse:
      type: object
      properties:
        message:
          type: string
        pattern_id:
          type: string
        removed:
          type: array
          items:
            type: string
        failed:
          type: array
          items:
            type: object
            additionalProperties: true
          description: Per-VM failures, each naming the VM and why it could not be removed.
        total:
          type: integer
        success:
          type: integer
          description: How many VMs left the pattern, releases included. A COUNT, not a boolean.
        released_targets:
          type: array
          items:
            $ref: '#/components/schemas/DRBlockedCleanupTarget'
          description: 'Targets this request removed from the pattern by RELEASING them rather than destroying
            them. Two dispositions arrive here and they are NOT the same thing, so read each entry’s `remedy`:
            a `promoted` target is a completed migration whose VM is production and is kept, while a `reconciliation_required`
            one is a target whose destination VM and replica volumes Sendense cannot account for and will
            NEVER delete — reconcile those yourself. In both cases the membership row is gone and the
            target record and the destination are untouched. `status` here is the target’s LIFECYCLE status
            and is usually an ordinary replication state: the cutover safety state, not the status, decides
            the release. A release is a success, so those VMs also appear under `removed`.'
        controller_vm_status:
          type: string
          enum:
          - cleanup_queued_or_not_required
          - released_not_destroyed
          - mixed_cleanup_and_release
        orphan_inventory:
          type: string
          description: Present only when a removal stranded a target; names the route that lists them.
      required:
      - message
      - pattern_id
      - total
      - success
    DRPatternCommitFailure:
      type: object
      properties:
        target_id:
          type: string
        vm_name:
          type: string
        error:
          type: string
      required:
      - target_id
      - error
    DRPatternCommitResponse:
      type: object
      description: The shape failover.PatternCommitResult actually serialises. `committed` and `failed`
        are ARRAYS, not counts — the published integer form was wrong in both fields and omitted `job_id`
        — and the original 200 carries no `fully_committed`; that field exists only in the Idempotency-Key
        replay payload. Read `failed.length === 0` here.
      properties:
        pattern_id:
          type: string
        job_id:
          type: string
          description: The pattern failover job the commit acted on.
        committed:
          type: array
          items:
            type: string
          description: Replication target IDs that committed.
        failed:
          type: array
          items:
            $ref: '#/components/schemas/DRPatternCommitFailure'
          description: Per-VM failures. An empty array means every VM committed.
      additionalProperties: true
    DRPatternCreateRequest:
      type: object
      description: Omit `vm_context_ids` and `vm_assignments` to create a dormant zero-VM pattern.
      properties:
        name:
          type: string
          description: Unique within the site.
        description:
          type: string
        site_id:
          type: string
          description: Must name an existing site. This is the tenancy anchor.
        enabled:
          type: boolean
          description: Honoured on create; false ships the pattern dormant.
        sync_interval_minutes:
          type: integer
        allow_manual_full_sync:
          type: boolean
        template_id:
          type: string
        network_id:
          type: string
        replication_network_id:
          type: string
        disk_offering_id:
          type: string
        service_offering_id:
          type: string
          description: 'When a value-less create names an offering, the offering is looked up through
            the site''s (pinned) CloudStack credential: a custom offering without both compute values
            is refused 400 `CUSTOM_OFFERING_REQUIRES_COMPUTE`; an id the destination does not have is
            refused 400 `SERVICE_OFFERING_NOT_FOUND`; an offering that cannot be verified (destination
            unreachable) is refused 503 `SERVICE_OFFERING_UNVERIFIED` naming both remedies. Supplying
            both compute values does not skip the lookup: it only lets a create proceed when the destination
            cannot be asked (the values are then trusted, and provisioning verifies the offering again
            before deploying). Must be a CUSTOMIZABLE offering (the ones `listDRServiceOfferings` returns):
            a static offering is refused 400 `SERVICE_OFFERING_STATIC` whenever the destination can be
            asked, because provisioning could never deploy on it. See `default_cpu_number` for the verification
            rule.'
        zone_id:
          type: string
        destination_hypervisor:
          type: string
        destination_vault_credential_id:
          type: string
          description: Pin this when the site holds more than one destination credential.
        vm_context_ids:
          type: array
          items:
            type: string
          description: Optional. Attaching here runs licence admission immediately.
        vm_assignments:
          type: array
          items:
            type: object
            additionalProperties: true
          description: Optional per-VM attachment overrides.
        default_cpu_number:
          type: integer
          minimum: 1
          description: 'CPU count for each controller VM. REQUIRED together with `default_memory_mb`:
            only a CUSTOM (customizable) CloudStack service offering can provision a controller — provisioning
            sends a CPU count and memory size with every deploy and CloudStack refuses them on a static
            offering — and a custom offering needs both values. The offering is looked up through the
            site''s (pinned) credential when the create runs: custom without both values → 400 `CUSTOM_OFFERING_REQUIRES_COMPUTE`;
            static → 400 `SERVICE_OFFERING_STATIC`; unknown id → 400 `SERVICE_OFFERING_NOT_FOUND`. When
            the destination cannot be asked, a pattern that carries both values is accepted so a dormant
            pattern can be pre-created before its destination is reachable (a static offering would then
            fail at controller creation); one without them is refused 503 `SERVICE_OFFERING_UNVERIFIED`.'
        default_memory_mb:
          type: integer
          minimum: 1
          description: Memory in MB for each controller VM. Same rule as `default_cpu_number`.
        max_concurrent_syncs:
          type: integer
          description: Per-pattern cap on concurrent syncs. Omit for the appliance default; on update,
            0 (or a negative value) clears the override.
        max_concurrent_provisions:
          type: integer
          description: Per-pattern cap on concurrent controller provisions. Omit for the default (1, serial);
            on update, 0 clears the override.
        replication_route_policy:
          type: string
          enum:
          - auto
          - via_sha
          - direct_p2p
          description: 'How replication traffic reaches the controller: `auto` (default) lets the appliance
            choose, `via_sha` relays through the hub, `direct_p2p` connects the source SNA to the controller
            directly.'
      required:
      - name
      - template_id
      - network_id
    DRPatternCreateResponse:
      type: object
      properties:
        pattern:
          $ref: '#/components/schemas/DRPattern'
        assignments:
          type: array
          items:
            $ref: '#/components/schemas/DRPatternAssignment'
          description: Present only when VMs were attached in the same call.
        provisioning_started:
          type: integer
          description: Number of VMs whose provisioning began. Present only when VMs were attached in
            the same call.
      required:
      - pattern
    DRPatternDeleteRefusal:
      type: object
      required:
      - error
      - pattern_id
      properties:
        error:
          type: string
          description: Present on every 409 (both refusal shapes).
        pattern_id:
          type: string
        stranded_targets:
          type: array
          items:
            type: object
            additionalProperties: true
          description: Targets the pattern claims but does not name that carry failover history; each
            names its `target_id`, `source_vm_name`, `status`, `failover_jobs` and whether the orphaned-target
            inventory can reach it.
        resolution:
          type: string
        orphan_inventory:
          type: string
          description: '`GET /api/v1/dr/orphaned-targets`'
        controller_action:
          type: string
        message:
          type: string
        controller_vms_action:
          type: string
        queued_target_ids:
          type: array
          items:
            type: string
        blocked_targets:
          type: array
          items:
            $ref: '#/components/schemas/DRBlockedCleanupTarget'
        released_targets:
          type: array
          items:
            $ref: '#/components/schemas/DRBlockedCleanupTarget'
          description: 'Targets this request removed from the pattern by RELEASING them rather than destroying
            them. Two dispositions arrive here and they are NOT the same thing, so read each entry’s `remedy`:
            a `promoted` target is a completed migration whose VM is production and is kept, while a `reconciliation_required`
            one is a target whose destination VM and replica volumes Sendense cannot account for and will
            NEVER delete — reconcile those yourself. In both cases the membership row is gone and the
            target record and the destination are untouched. `status` here is the target’s LIFECYCLE status
            and is usually an ordinary replication state: the cutover safety state, not the status, decides
            the release.'
    DRPatternDeleteResponse:
      type: object
      required:
      - message
      - pattern_id
      - controller_vms_action
      properties:
        message:
          type: string
        pattern_id:
          type: string
        controller_vms_action:
          type: string
          enum:
          - destroying
          - partially_destroying
          - not_applicable
          description: '`destroying` accompanies the 202: cleanup is running and the pattern deletes itself
            when it completes. `partially_destroying` is a 202 where `blocked_targets` names targets that
            could not be queued (a destroy retry, or the remedy for a target a failover touched). `not_applicable`
            accompanies the 200: nothing to destroy, the pattern is already gone — possibly after releasing
            targets (`released_targets`).'
        queued_target_ids:
          type: array
          items:
            type: string
          description: Targets whose controller destroy was queued on this request. Present on `partially_destroying`,
            and on a plain 202 when the request also released targets.
        blocked_targets:
          type: array
          items:
            $ref: '#/components/schemas/DRBlockedCleanupTarget'
          description: 'Targets whose cleanup could not start: a `delete_failed` one with its `retry_endpoint`
            — an appliance-operator route not published in the provider profile, and one a provider cannot
            substitute for while the pattern still names the target — a target a failover touched or one
            whose row could not be read with its `remedy`. Present on `partially_destroying`.'
        deferred:
          type: boolean
          description: True on a 202 where some destroys could not be queued on this request; the appliance
            retries them and the pattern completes without a second call.
        details:
          type: string
          description: 'Why a destroy was deferred (with `deferred: true`), or — on a 202 where nothing
            was queued after releases — why the pattern is retained for the appliance''s sweep.'
        released_targets:
          type: array
          items:
            $ref: '#/components/schemas/DRBlockedCleanupTarget'
          description: 'Targets this request removed from the pattern by RELEASING them rather than destroying
            them. Two dispositions arrive here and they are NOT the same thing, so read each entry’s `remedy`:
            a `promoted` target is a completed migration whose VM is production and is kept, while a `reconciliation_required`
            one is a target whose destination VM and replica volumes Sendense cannot account for and will
            NEVER delete — reconcile those yourself. In both cases the membership row is gone and the
            target record and the destination are untouched. `status` here is the target’s LIFECYCLE status
            and is usually an ordinary replication state: the cutover safety state, not the status, decides
            the release.'
    DRPatternDetachRefusal:
      type: object
      required:
      - error
      properties:
        error:
          type: string
        message:
          type: string
        pattern_id:
          type: string
        vm_context_id:
          type: string
        controller_vm_status:
          type: string
          enum:
          - blocked
          description: '`blocked` when cleanup could not be queued. This is the REFUSAL body''s field
            and its vocabulary is not the success body''s — enumerating the success values here would
            have promised a set that never appears on a refusal.'
        blocked_targets:
          type: array
          items:
            $ref: '#/components/schemas/DRBlockedCleanupTarget'
          description: 'The target that could not be cleaned up: a `delete_failed` one with its `retry_endpoint`,
            or a target a failover touched with its `remedy`.'
        released_targets:
          type: array
          items:
            $ref: '#/components/schemas/DRBlockedCleanupTarget'
          description: 'Targets this request removed from the pattern by RELEASING them rather than destroying
            them. Two dispositions arrive here and they are NOT the same thing, so read each entry’s `remedy`:
            a `promoted` target is a completed migration whose VM is production and is kept, while a `reconciliation_required`
            one is a target whose destination VM and replica volumes Sendense cannot account for and will
            NEVER delete — reconcile those yourself. In both cases the membership row is gone and the
            target record and the destination are untouched. `status` here is the target’s LIFECYCLE status
            and is usually an ordinary replication state: the cutover safety state, not the status, decides
            the release.'
    DRPatternDetailResponse:
      type: object
      properties:
        pattern:
          $ref: '#/components/schemas/DRPattern'
        assignments:
          type: array
          items:
            $ref: '#/components/schemas/DRPatternAssignment'
        by_status:
          type: object
          additionalProperties:
            type: integer
          description: Assignment counts keyed by status.
      required:
      - pattern
    DRPatternFailoverActivityResponse:
      type: object
      properties:
        pattern_id:
          type: string
        activity:
          type: array
          items:
            type: object
            additionalProperties: true
      required:
      - pattern_id
      - activity
    DRPatternFailoverCancelResponse:
      type: object
      properties:
        cancelled:
          type: boolean
        message:
          type: string
      required:
      - cancelled
      - message
    DRPatternFailoverJob:
      type: object
      properties:
        id:
          type: string
          description: Pattern failover job identifier, returned by the trigger.
        pattern_id:
          type: string
        failover_type:
          type: string
          description: test, planned or unplanned.
        status:
          type: string
          description: Job lifecycle state.
        total_vms:
          type: integer
          description: VMs in scope for this cutover.
        completed_vms:
          type: integer
        failed_vms:
          type: integer
        skipped_vms:
          type: integer
        parallel_limit:
          type: integer
          description: How many VMs cut over at once.
        continue_on_failure:
          type: boolean
        vm_ordering:
          type: string
        excluded_vms:
          type: string
          description: VMs deliberately left out of a selective pattern operation.
        triggered_by:
          type: string
          description: Display metadata only. The audit actor is resolved from the authenticated identity,
            never from this field.
        current_phase:
          type:
          - string
          - 'null'
        error_summary:
          type:
          - string
          - 'null'
        created_at:
          type: string
          format: date-time
        started_at:
          type:
          - string
          - 'null'
          format: date-time
        completed_at:
          type:
          - string
          - 'null'
          format: date-time
        updated_at:
          type: string
          format: date-time
      description: A pattern-wide failover job, covering every VM in the pattern unless excluded.
    DRPatternFailoverJobResponse:
      type: object
      properties:
        job:
          $ref: '#/components/schemas/DRPatternFailoverJob'
        vm_jobs:
          type: array
          items:
            $ref: '#/components/schemas/DRVMJobSummary'
        summary:
          type: object
          additionalProperties: true
          description: Aggregate progress across the pattern's VMs.
      required:
      - job
    DRPatternFailoverRequest:
      type: object
      properties:
        failover_type:
          type: string
          enum:
          - test
          - planned
          - unplanned
          description: 'Selects the required permission: test -> dr.failover.test; planned and unplanned
            -> dr.failover.live.'
        triggered_by:
          type: string
          description: Display metadata only.
        confirm:
          type: string
          description: Typed confirmation matching the pattern's name. Required for a live cutover under
            enforcement.
        reason:
          type: string
          description: Required alongside `confirm`.
        parallel_limit:
          type: integer
          description: How many VMs cut over at once.
        continue_on_failure:
          type: boolean
        vm_order:
          type: array
          items:
            type: string
          description: Explicit cutover order by target id.
        excluded_vms:
          type: array
          items:
            type: string
          description: Targets to leave out — this is how a selective pattern operation is expressed.
        windows_guest_network_mode:
          type: string
        windows_guest_callback_mode:
          type: string
      required:
      - failover_type
    DRPatternFailoverRetryRequest:
      type: object
      properties:
        target_ids:
          type: array
          items:
            type: string
          description: Omit to retry every failed VM.
        triggered_by:
          type: string
          description: Display metadata only.
    DRPatternFailoverRetryResponse:
      type: object
      properties:
        retry:
          type: object
          additionalProperties: true
          description: Which targets were retried and which were skipped.
        message:
          type: string
      required:
      - retry
      - message
    DRPatternFailoverTriggerResponse:
      type: object
      properties:
        job:
          $ref: '#/components/schemas/DRPatternFailoverJob'
        message:
          type: string
      required:
      - job
      - message
    DRPatternHandlerError:
      type: object
      description: 'The pattern handlers'' own error envelope: a message and the HTTP status repeated
        as an integer.'
      properties:
        error:
          type: string
        status:
          type: integer
      required:
      - error
      - status
    DRPatternIrreversibleRefusal:
      description: '400: either a refused typed confirmation (`code`, `confirm_with`) or the orchestrator''s
        own refusal (`{error, status}`).'
      anyOf:
      - $ref: '#/components/schemas/DRConfirmationRefusal'
      - $ref: '#/components/schemas/DRPatternHandlerError'
    DRPatternListResponse:
      type: object
      properties:
        patterns:
          type: array
          items:
            $ref: '#/components/schemas/DRPattern'
        total:
          type: integer
      required:
      - patterns
      - total
    DRPatternMetricsResponse:
      type: object
      properties:
        success:
          type: boolean
        pattern_id:
          type: string
        pattern_name:
          type: string
        period:
          type: string
        total_syncs:
          type: integer
        successful_syncs:
          type: integer
        failed_syncs:
          type: integer
        success_rate:
          type: number
        total_bytes_transferred:
          type: integer
        average_speed_mbs:
          type: number
        syncs_by_hour:
          type: array
          items:
            type: object
            additionalProperties: true
        success_rate_trend:
          type: array
          items:
            type: object
            additionalProperties: true
        speed_trend:
          type: array
          items:
            type: object
            additionalProperties: true
      required:
      - success
      - pattern_id
      - period
    DRPatternRPOStatusResponse:
      type: object
      properties:
        success:
          type: boolean
        pattern_id:
          type: string
        rpo_minutes:
          type: integer
        targets:
          type: array
          items:
            type: object
            additionalProperties: true
          description: Per-target RPO rows. An empty array, never null.
        assignment_count:
          type: integer
          description: Visible assignments, BEFORE the skip-unprovisioned filter. Read with summary.total
            to tell 'no VMs assigned' from 'assigned but provisioning'.
        compliance_rate:
          type: number
        average_rpo_minutes:
          type: number
        summary:
          type: object
          properties:
            compliant:
              type: integer
            warning:
              type: integer
            breached:
              type: integer
            total:
              type: integer
              description: Materialised targets only.
      required:
      - success
      - pattern_id
      - targets
      - assignment_count
      - summary
    DRPatternRemoveVMResponse:
      type: object
      properties:
        message:
          type: string
        pattern_id:
          type: string
        vm_context_id:
          type: string
        controller_vm_status:
          type: string
          description: '`cleanup_queued_or_not_required` when every target this detach resolved to was
            torn down or there was nothing to tear down; `released_not_destroyed` when every one was RELEASED
            instead — its record and its destination are untouched; `mixed_cleanup_and_release` when the
            detach resolved to several targets (the assignment''s own, plus any unlinked sibling it adopted)
            that did not share a disposition. `released_targets` is present for the latter two and says
            which disposition each one is.'
          enum:
          - cleanup_queued_or_not_required
          - released_not_destroyed
          - mixed_cleanup_and_release
        released_targets:
          type: array
          items:
            $ref: '#/components/schemas/DRBlockedCleanupTarget'
          description: 'Present when `controller_vm_status` is `released_not_destroyed` or `mixed_cleanup_and_release`.
            The target left the pattern by being RELEASED rather than destroyed: read its `cutover_safety_state`
            and `remedy` for whether this is a completed migration whose VM is production, or a target
            whose destination resources Sendense cannot account for and will never delete.'
      required:
      - message
      - pattern_id
      - vm_context_id
    DRPatternRollbackFailure:
      type: object
      properties:
        target_id:
          type: string
        vm_name:
          type: string
        error:
          type: string
      additionalProperties: true
    DRPatternRollbackResponse:
      type: object
      description: The shape failover.PatternRollbackResult actually serialises. `rolled_back` and `failed`
        are ARRAYS, not counts — the published integer form was wrong in both fields and omitted `job_id`
        entirely, and the GUI's own code reads `rolled_back.length`.
      properties:
        pattern_id:
          type: string
        job_id:
          type: string
          description: The pattern failover job the rollback acted on.
        rolled_back:
          type: array
          items:
            type: string
          description: Replication target IDs that rolled back.
        failed:
          type: array
          items:
            $ref: '#/components/schemas/DRPatternRollbackFailure'
          description: Per-VM failures. An empty array means every VM rolled back.
      additionalProperties: true
    DRPatternSyncHistoryResponse:
      type: object
      properties:
        success:
          type: boolean
        pattern_id:
          type: string
        syncs:
          type: array
          items:
            type: object
            additionalProperties: true
        total:
          type: integer
      required:
      - success
      - pattern_id
      - syncs
      - total
    DRPatternSyncJob:
      type: object
      properties:
        trigger_job_id:
          type: string
          description: The id returned by the sync trigger's 202.
        pattern_id:
          type: string
          description: The pattern this dispatch belongs to. Always the one in the path — a job belonging
            to any other pattern answers 404.
        status:
          type: string
          description: The dispatch's job-tracking status.
        percent_complete:
          type: integer
        sync_type:
          type: string
          description: full or incremental, as the trigger requested.
        trigger_type:
          type: string
          description: manual or scheduled, as the trigger requested.
        error:
          type: string
          description: Present only when the dispatch failed.
        created_at:
          type: string
          format: date-time
        started_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
        terminal:
          type: boolean
          description: Whether polling can stop. An UNRECOGNISED status reports false, so an unfamiliar
            state makes you keep polling rather than abandon a dispatch that is still working.
      required:
      - trigger_job_id
      - pattern_id
      - status
      - percent_complete
      - created_at
      - terminal
    DRPatternSyncRequest:
      type: object
      properties:
        sync_type:
          type: string
          enum:
          - full
          - incremental
          description: Defaults to incremental.
        trigger_type:
          type: string
          enum:
          - manual
          - scheduled
          description: Defaults to manual.
        triggered_by:
          type: string
          description: Display metadata only; the audit actor comes from the authenticated identity.
    DRPatternSyncResponse:
      type: object
      properties:
        trigger_job_id:
          type: string
          description: Job-tracking record for the dispatch. Resolve it with GET /api/v1/dr/patterns/{pattern_id}/sync-jobs/{job_id}.
        pattern_id:
          type: string
        message:
          type: string
      required:
      - pattern_id
      - message
    DRPatternUpdateRequest:
      type: object
      description: Only the supplied fields change.
      properties:
        name:
          type: string
        description:
          type: string
        enabled:
          type: boolean
        sync_interval_minutes:
          type: integer
        allow_manual_full_sync:
          type: boolean
        site_id:
          type: string
        template_id:
          type: string
        network_id:
          type: string
        replication_network_id:
          type: string
        disk_offering_id:
          type: string
        service_offering_id:
          type: string
        zone_id:
          type: string
        destination_vault_credential_id:
          type: string
        default_cpu_number:
          type: integer
          minimum: 1
          description: New CPU count. An explicit non-positive value is refused 400; an update that would
            leave a custom offering without both compute values (clearing one, or moving the pattern onto
            a custom offering without them) is refused 400 `CUSTOM_OFFERING_REQUIRES_COMPUTE` and writes
            nothing.
        default_memory_mb:
          type: integer
          minimum: 1
          description: New memory in MB. Same rule as `default_cpu_number`.
        max_concurrent_syncs:
          type: integer
          description: Per-pattern cap on concurrent syncs. Omit for the appliance default; on update,
            0 (or a negative value) clears the override.
        max_concurrent_provisions:
          type: integer
          description: Per-pattern cap on concurrent controller provisions. Omit for the default (1, serial);
            on update, 0 clears the override.
        replication_route_policy:
          type: string
          enum:
          - auto
          - via_sha
          - direct_p2p
          description: 'How replication traffic reaches the controller: `auto` (default) lets the appliance
            choose, `via_sha` relays through the hub, `direct_p2p` connects the source SNA to the controller
            directly.'
    DRRollbackRetryConflict:
      description: '409: either the job is not a failed rollback (`ROLLBACK_RETRY_NOT_ELIGIBLE`) or another
        operation holds the target''s lock (`OPERATION_BLOCKED`, an `{error, code}` body naming the blocking
        job).'
      anyOf:
      - $ref: '#/components/schemas/DRRollbackRetryNotEligible'
      - $ref: '#/components/schemas/DRRollbackTargetConflict'
    DRRollbackRetryNotEligible:
      type: object
      description: The job is not a failed rollback. `status` is the job's current status; `eligible_status`
        is the only status this route accepts.
      properties:
        error:
          type: string
          description: Includes a remedy that is true for the status being refused.
        code:
          type: string
          enum:
          - ROLLBACK_RETRY_NOT_ELIGIBLE
        status:
          type: string
        eligible_status:
          type: string
          enum:
          - rollback_failed
      required:
      - error
      - code
      - status
      - eligible_status
    DRRollbackRetryResponse:
      type: object
      properties:
        message:
          type: string
        job_id:
          type: string
      required:
      - message
      - job_id
    DRRollbackTargetConflict:
      type: object
      description: The operation lock refused a concurrent rollback.
      properties:
        error:
          type: string
          description: Names the blocking operation and job.
        code:
          type: string
          enum:
          - OPERATION_BLOCKED
      required:
      - error
      - code
    DRRollbackTargetRefusal:
      description: '400: either a plain error because no rollback-eligible failover exists for the target
        (checked FIRST, `{error}` only), or a refused typed confirmation carrying `code` and `confirm_with`.'
      anyOf:
      - $ref: '#/components/schemas/ErrorResponse'
      - $ref: '#/components/schemas/DRConfirmationRefusal'
    DRRollbackTriggerResponse:
      type: object
      properties:
        message:
          type: string
        failover_job_id:
          type: string
        failover_type:
          type: string
      required:
      - message
      - failover_job_id
    DRScheduleResponse:
      type: object
      properties:
        target_id:
          type: string
        sync_enabled:
          type: boolean
        sync_interval_minutes:
          type: integer
        last_sync_at:
          type:
          - string
          - 'null'
          format: date-time
        next_sync_at:
          type:
          - string
          - 'null'
          format: date-time
      required:
      - target_id
      - sync_enabled
      - sync_interval_minutes
    DRScheduleUpdateRequest:
      type: object
      properties:
        sync_enabled:
          type: boolean
        sync_interval_minutes:
          type: integer
    DRServiceOfferingRefusal:
      type: object
      required:
      - error
      properties:
        error:
          type: string
        status:
          type: integer
        details:
          type: string
        code:
          type: string
          enum:
          - CUSTOM_OFFERING_REQUIRES_COMPUTE
          - SERVICE_OFFERING_STATIC
          - SERVICE_OFFERING_NOT_FOUND
          - SERVICE_OFFERING_UNVERIFIED
        service_offering_id:
          type: string
        site_id:
          type: string
        missing:
          type: array
          items:
            type: string
            enum:
            - default_cpu_number
            - default_memory_mb
        resolution:
          type: string
      description: The structured refusals of the service-offering rule. A generic `error`/`status`/`details`
        body (no `code`) is any other invalid request.
    DRSiteDestinationDefaults:
      type: object
      additionalProperties: true
      properties:
        site_id:
          type: string
        template_id:
          type: string
        network_id:
          type: string
        disk_offering_id:
          type: string
        service_offering_id:
          type: string
        zone_id:
          type: string
      description: The destination placement defaults resolved for a site.
    DRSuccessRateResponse:
      type: object
      additionalProperties: true
      properties:
        period:
          type: string
        bucket:
          type: string
        buckets:
          type: array
          items:
            type: object
            additionalProperties: true
      description: Bucketed replication success-rate trend.
    DRSyncCancelResponse:
      type: object
      properties:
        message:
          type: string
        job_id:
          type: string
      required:
      - message
      - job_id
    DRSyncJob:
      type: object
      properties:
        id:
          type: string
          description: Sync job identifier, returned by a sync trigger.
        replication_target_id:
          type: string
        source_appliance_id:
          type:
          - string
          - 'null'
        destination_appliance_id:
          type:
          - string
          - 'null'
        route_id:
          type:
          - string
          - 'null'
        routing_session_id:
          type:
          - string
          - 'null'
        source_platform:
          type: string
        destination_hypervisor:
          type: string
        sync_type:
          type: string
          description: '`full` or `incremental`.'
        trigger_type:
          type: string
        requested_vmware_transfer_mode:
          type: string
        effective_vmware_transfer_mode:
          type: string
        transfer_mode_resolution_note:
          type: string
        requested_vmware_hotadd_profile:
          type: string
        effective_vmware_hotadd_profile:
          type: string
        requested_vmware_hotadd_reader:
          type: string
        effective_vmware_hotadd_reader:
          type: string
        hotadd_reader_resolution_note:
          type: string
        vmware_snapshot_status:
          type: string
        vmware_snapshot_ref:
          type: string
        vmware_snapshot_name:
          type: string
        vmware_snapshot_error:
          type: string
        vmware_snapshot_updated_at:
          type:
          - string
          - 'null'
          format: date-time
        cloudstack_source_snapshot_id:
          type: string
        cloudstack_source_snapshot_name:
          type: string
        cloudstack_source_snapshot_deleted_at:
          type:
          - string
          - 'null'
          format: date-time
        cloudstack_source_snapshot_cleanup_status:
          type: string
        cloudstack_source_snapshot_cleanup_attempted_at:
          type:
          - string
          - 'null'
          format: date-time
        cloudstack_source_snapshot_cleanup_error:
          type: string
        cloudstack_source_snapshot_cleanup_attempts:
          type: integer
        status:
          type: string
          description: Job lifecycle state.
        current_phase:
          type: string
        phase_started_at:
          type:
          - string
          - 'null'
          format: date-time
        phase_deadline_at:
          type:
          - string
          - 'null'
          format: date-time
        bytes_transferred:
          type: integer
        total_bytes:
          type: integer
        progress_percent:
          type: number
          description: Completion percentage.
        transfer_speed_bps:
          type: integer
        started_at:
          type:
          - string
          - 'null'
          format: date-time
        completed_at:
          type:
          - string
          - 'null'
          format: date-time
        eta_seconds:
          type: integer
        last_heartbeat_at:
          type:
          - string
          - 'null'
          format: date-time
        last_progress_at:
          type:
          - string
          - 'null'
          format: date-time
        error_message:
          type: string
          description: Failure detail when the job did not complete.
        terminal_reason:
          type: string
        recovery_action:
          type: string
        failed_disk_index:
          type:
          - integer
          - 'null'
        owner_process_id:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      description: One replication sync job for a target.
    DRSyncJobListResponse:
      type: object
      properties:
        jobs:
          type: array
          items:
            $ref: '#/components/schemas/DRSyncJob'
        total:
          type: integer
        next_cursor:
          type: string
          description: Empty when there is no further page.
      required:
      - jobs
      - total
    DRSyncProgressResponse:
      type: object
      additionalProperties: true
      properties:
        job_id:
          type: string
        status:
          type: string
        progress_percent:
          type: number
        bytes_transferred:
          type: integer
        total_bytes:
          type: integer
        transfer_speed_bps:
          type: integer
        eta_seconds:
          type: integer
      description: Live progress for one sync job.
    DRSyncRetryResponse:
      type: object
      properties:
        message:
          type: string
        job:
          $ref: '#/components/schemas/DRSyncJob'
        old_job_id:
          type: string
          description: The failed job this retry replaces.
        target_id:
          type: string
      required:
      - message
      - job
    DRSyncSummaryResponse:
      type: object
      additionalProperties: true
      properties:
        target_id:
          type: string
        period:
          type: string
        window:
          type: object
          properties:
            start:
              type: string
            end:
              type: string
        counts:
          type: object
          properties:
            completed:
              type: integer
            failed:
              type: integer
            running:
              type: integer
            pending:
              type: integer
            cancelled:
              type: integer
        bytes_transferred:
          type: integer
      required:
      - target_id
      - period
      - counts
    DRSyncTriggerRequest:
      type: object
      properties:
        sync_type:
          type: string
          enum:
          - full
          - incremental
          description: Defaults to incremental.
        trigger_type:
          type: string
          enum:
          - manual
          - scheduled
        triggered_by:
          type: string
          description: Display metadata only.
    DRSyncTriggerResponse:
      type: object
      properties:
        message:
          type: string
        job:
          $ref: '#/components/schemas/DRSyncJob'
      required:
      - message
      - job
    DRTarget:
      type: object
      properties:
        id:
          type: string
          description: Replication target identifier. Available once the pattern assignment materialises
            a target.
        vm_context_id:
          type: string
          description: The source VM this target replicates.
        source_platform:
          type: string
        source_vm_name:
          type: string
          description: Source VM name. This is the value a typed confirmation must match for an irreversible
            operation on this target.
        source_vm_id:
          type: string
        source_vault_credential_id:
          type:
          - string
          - 'null'
        source_vcenter_host:
          type:
          - string
          - 'null'
        destination_sha_id:
          type: string
        destination_sha_name:
          type: string
        destination_hypervisor:
          type: string
        destination_config:
          type: object
          additionalProperties: true
        destination_vault_credential_id:
          type:
          - string
          - 'null'
        controller_vm_id:
          type: string
          description: The Sendense DR **controller** VM in the destination cloud. Before commit this
            is Sendense infrastructure, NOT the customer's production workload — do not publish it to
            an orchestrator as such. At commit this same VM is promoted, renamed, and recorded in the
            promoted_* fields below, after which the target no longer owns a controller.
        controller_vm_name:
          type: string
        controller_vm_ip:
          type: string
          description: Controller VM address, reported by the controller agent's heartbeat.
        manual_controller_vm_ip_hint:
          type: string
        manual_controller_vm_ip_hint_error:
          type: string
        manual_controller_vm_ip_hint_updated_at:
          type:
          - string
          - 'null'
          format: date-time
        site_id:
          type:
          - string
          - 'null'
          description: The owning site. This is the tenancy anchor for every gate on this target.
        controller_agent_version:
          type: string
        controller_agent_heartbeat:
          type:
          - string
          - 'null'
          format: date-time
        promoted_vm_id:
          type:
          - string
          - 'null'
          description: The destination VM that became production. Written **only at commit**. Equal to
            the controller VM id by construction, because the controller VM is what gets promoted.
        promoted_vm_name:
          type:
          - string
          - 'null'
          description: The promoted VM's name after the rename to the source VM's name. Written only at
            commit.
        promoted_cloudstack_instance_name:
          type:
          - string
          - 'null'
          description: 'The destination platform''s own instance name for the promoted VM. **Best effort**:
            it is read back with a live call whose failure is logged and swallowed, so it can legitimately
            be empty on an otherwise successful commit. Absence is not commit failure.'
        promoted_vault_credential_id:
          type:
          - string
          - 'null'
        promoted_at:
          type:
          - string
          - 'null'
          format: date-time
          description: When the commit promoted the VM. Its presence is the reliable signal that commit
            completed.
        promoted_rename_status:
          type:
          - string
          - 'null'
          description: Whether the rename to the source VM's name succeeded. The honest signal for 'production
            VM exists but is still named after the controller'.
        promoted_rename_error:
          type:
          - string
          - 'null'
        status:
          type: string
          description: Lifecycle state. `pending_commit` means a live or planned failover is awaiting
            commit; `committed` means it is finalised and no rollback exists.
        cutover_safety_state:
          type: string
          enum:
          - ordinary
          - cutover_protected
          - promoted
          - reconciliation_required
          description: 'Whether an ordinary Sendense cleanup path may delete this target’s destination
            VM and volumes. This is NOT a second lifecycle: `status` says what the target is doing, this
            says what may be done TO its destination resources, and it is the value every destruction
            path reads. `ordinary` — no failover has left the workload at the destination; teardown, reprovisioning
            and release all apply. `cutover_protected` — an active or incomplete failover may leave the
            workload at the destination. A successful explicit CloudStack or VMware ROLLBACK is the only
            route that returns a started cutover to `ordinary`; a commit moves it to `promoted`. Every
            other cleanup route — the failover job’s cleanup, force cleanup, the reaper, boot recovery
            — retires the operation to `reconciliation_required` instead, because this release does not
            certify an automatically compensated destination as safe to destroy. `promoted` — a commit
            completed and the destination VM is production; the migration is finished and the VM is never
            destroyed by Sendense. `reconciliation_required` — Sendense cannot PROVE the destination resources
            are absent, and the state is TERMINAL. Such a target cannot be destroyed, cannot be reprovisioned,
            cannot start another failover, and is never automatically stale-cleaned; there is no route
            back to `ordinary`. Sendense will never delete its destination resources. Release its pattern
            membership — which makes zero provider-resource calls — reconcile the destination VM and volumes
            yourself, then remove the DR record through the confirmed orphaned-target removal, which deletes
            records only and states that destination resources may remain unmanaged. To protect the same
            VM again, create a new target. Every value but `ordinary` answers **409** to the destroy routes
            and to a reprovision that would tear the target down first. This field carries the raw column,
            so it is always one of the four values above; the orphaned-target plan and the destroy refusal
            normalise an unreadable or unrecognised value to `unknown` and declare that fifth value in
            their own schemas.'
        cutover_safety_reason:
          type: string
          description: Why the cutover safety state was last written. Diagnostic; nothing decides anything
            from it.
        cutover_safety_updated_at:
          type:
          - string
          - 'null'
          format: date-time
          description: When the cutover safety state was last written.
        sync_interval_minutes:
          type: integer
        last_sync_at:
          type:
          - string
          - 'null'
          format: date-time
          description: Last successful sync.
        next_sync_at:
          type:
          - string
          - 'null'
          format: date-time
          description: Next scheduled sync.
        sync_enabled:
          type: boolean
        vmware_transfer_mode:
          type: string
        vmware_hotadd_profile:
          type: string
        failover_os_type_id:
          type:
          - string
          - 'null'
        failover_os_type_label:
          type:
          - string
          - 'null'
        total_syncs:
          type: integer
        successful_syncs:
          type: integer
        failed_syncs:
          type: integer
        total_bytes_transferred:
          type: integer
        last_error:
          type: string
        last_error_at:
          type:
          - string
          - 'null'
          format: date-time
        consecutive_failures:
          type: integer
        provision_started_at:
          type:
          - string
          - 'null'
          format: date-time
        provision_last_attempt_at:
          type:
          - string
          - 'null'
          format: date-time
        provision_retry_count:
          type: integer
        provision_timeout_at:
          type:
          - string
          - 'null'
          format: date-time
        provision_last_error:
          type: string
        provisioning_state:
          type: string
        assignment_pattern_id:
          type: string
        is_orphaned:
          type: boolean
          description: True when no pattern assignment claims this target. Orphans block a site delete
            and are listed at /api/v1/dr/orphaned-targets.
        locked_by_job_id:
          type:
          - string
          - 'null'
        locked_by_operation:
          type:
          - string
          - 'null'
          description: 'The operation currently holding this target''s lock. This is mutual exclusion,
            NOT idempotency: it rejects a concurrent second request and releases on completion.'
        lock_acquired_at:
          type:
          - string
          - 'null'
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      description: 'One VM''s replication target: the destination-side state of a protected VM, from provisioning
        through sync, failover and commit.'
    DRTargetDestroyRefusal:
      type: object
      description: '409: the destination workload is not something an ordinary cleanup path may delete.
        The body distinguishes the three outcomes a caller must not confuse — CLEANUP deletes the destination
        resources, RELEASE removes only the pattern membership, and RECONCILIATION means Sendense cannot
        prove the destination resources are absent and will never delete them — and `remedy` always names
        an action that exists.'
      properties:
        error:
          type: string
        code:
          type: string
          enum:
          - cutover_protected
          - promoted
          - reconciliation_required
          - cutover_status
          - operation_lock_held
          - safety_state_unknown
          - target_unreadable
          - target_not_found
          description: Why the cleanup was refused. The first three are the persisted cutover safety state.
            `cutover_status` and `operation_lock_held` are independent vetoes that fire on a target whose
            state reads ordinary but whose lifecycle or lock says otherwise. `safety_state_unknown` means
            this appliance does not recognise the recorded value and `target_unreadable` means the row
            could not be read — both fail closed and are retryable once the cause is resolved. `target_not_found`
            means there is no such target; the routes answer that **404**, not 409, so it appears here
            only for completeness.
        target_id:
          type: string
        cutover_safety_state:
          type: string
          description: The target’s recorded cutover safety state, or `unknown` when it could not be read
            or classified.
          enum:
          - ordinary
          - cutover_protected
          - promoted
          - reconciliation_required
          - unknown
        status:
          type: string
        promoted_vm_id:
          type: string
          description: 'Present when the refusal is about a promoted workload: the destination VM that
            is now production.'
        blocking_operation:
          type: string
        blocking_job_id:
          type: string
        remedy:
          type: string
          description: The documented exit for this state. Never empty.
      required:
      - error
      - code
      - target_id
      - cutover_safety_state
      - remedy
    DRTargetDetailResponse:
      type: object
      properties:
        target:
          $ref: '#/components/schemas/DRTarget'
        disks:
          type: array
          items:
            $ref: '#/components/schemas/DRDisk'
        root_disk_index:
          type: integer
          description: Layout-inferred boot disk. Do not assume source index 0 is the root.
        cpu_compat_status:
          type: string
          description: 'Destination CPU-baseline verdict: ok, blocked, warn or unknown.'
        cpu_compat_details:
          type: string
        destroy_cleanup:
          type: object
          additionalProperties: true
          description: Present only while a teardown is in progress or has failed.
      required:
      - target
      - disks
    DRTargetHealthResponse:
      type: object
      additionalProperties: true
      properties:
        target_id:
          type: string
        healthy:
          type: boolean
        disks:
          type: array
          items:
            type: object
            additionalProperties: true
      description: Replica validation and health state for a target.
    DRTargetListResponse:
      type: object
      properties:
        targets:
          type: array
          items:
            $ref: '#/components/schemas/DRTarget'
        total:
          type: integer
      required:
      - targets
      - total
    DRTargetMessageResponse:
      type: object
      properties:
        message:
          type: string
        target_id:
          type: string
        acknowledged:
          type: boolean
          description: 'Present on the acknowledge-error route: the error was cleared.'
        status_reset:
          type: boolean
          description: Present on the acknowledge-error route. FALSE means the target's lifecycle status
            was deliberately NOT returned to `ready`, because its cutover safety state is not `ordinary`
            — returning it would re-arm sync and failover against a destination Sendense cannot account
            for.
        cutover_safety_state:
          type: string
          enum:
          - ordinary
          - cutover_protected
          - promoted
          - reconciliation_required
          - unknown
          description: 'Present when `status_reset` is false: the state that withheld the reset.'
      required:
      - message
      - target_id
    DRTargetProvisionRetryResponse:
      type: object
      properties:
        message:
          type: string
        target_id:
          type: string
        vm_context_id:
          type: string
          description: Present when the retry queued a controller reprovision.
      required:
      - message
      - target_id
    DRTargetUpdateRequest:
      type: object
      properties:
        sync_enabled:
          type: boolean
        sync_interval_minutes:
          type: integer
        failover_os_type_id:
          type: string
        destination_vault_credential_id:
          type: string
    DRVMJobSummary:
      type: object
      description: One VM's progress inside a pattern failover.
      properties:
        target_id:
          type: string
        vm_name:
          type: string
        status:
          type: string
        phase:
          type: string
        progress_percent:
          type: number
        error:
          type: string
        started_at:
          type: string
        completed_at:
          type: string
        guest_remediation_status:
          type: string
        guest_remediation_summary:
          type: string
        guest_remediation_reported_at:
          type: string
        guest_remediation:
          type: object
          additionalProperties: true
        windows_guest_remediation:
          type: object
          additionalProperties: true
      required:
      - target_id
      - vm_name
      - status
      - progress_percent
    DRVMReplicationStatusResponse:
      type: object
      properties:
        statuses:
          type: array
          items:
            type: object
            additionalProperties: true
            description: One VM's replication posture.
        total:
          type: integer
      required:
      - statuses
      - total
    DRValidateRequest:
      type: object
      properties:
        check_type:
          type: string
          description: Which validation to run, e.g. quick.
    DRValidateResponse:
      type: object
      properties:
        target_id:
          type: string
        check_type:
          type: string
        results:
          type: array
          items:
            type: object
            additionalProperties: true
      required:
      - target_id
      - results
    DetailedExecutionResponse:
      description: 'One discovery execution -- the DiscoveryExecution row flattened, plus parsed twins
        of its JSON-string columns. NOTE: the raw string columns `sites_included` / `excluded_appliances`
        (from the base) coexist with their decoded `*_array` fields.'
      allOf:
      - $ref: '#/components/schemas/DiscoveryExecution'
      - type: object
        properties:
          sites_included_array:
            type: array
            items:
              type: string
            description: Decoded `sites_included`; omitted when the raw column is null/unparseable. NAMES
              OTHER TENANTS for a global run (cross-tenant identifiers).
          excluded_appliances_array:
            type: array
            items:
              $ref: '#/components/schemas/ExcludedAppliance'
            description: Decoded `excluded_appliances`; omitted when null/unparseable.
    DiscoveryEnvelopeError:
      description: A response whose JSON body is EITHER the middleware/site-gate ErrorResponse OR the
        handler DiscoveryError, depending on which layer answered.
      oneOf:
      - $ref: '#/components/schemas/ErrorResponse'
      - $ref: '#/components/schemas/DiscoveryError'
    DiscoveryError:
      type: object
      description: The `unified`/`schedule`/`history` handler error envelope -- distinct from the middleware
        ErrorResponse.
      properties:
        status:
          type: string
          description: '`error`.'
        message:
          type: string
          description: Human-readable error; may echo raw upstream/source error text or the requested
            id.
      required:
      - status
      - message
    DiscoveryExecution:
      type: object
      description: A discovery execution row. Nullable pointer fields are always present, emitted as null
        when unset. `sites_included`/`excluded_appliances`/`error_summary` are JSON arrays stored AS STRINGS.
      properties:
        id:
          type: string
        schedule_id:
          type:
          - string
          - 'null'
        execution_type:
          type: string
          enum:
          - scheduled
          - manual
        site_id:
          type:
          - string
          - 'null'
        sites_included:
          type:
          - string
          - 'null'
          description: JSON array (as a string) of the site ids a global run spanned -- cross-tenant identifiers.
        status:
          type: string
          enum:
          - pending
          - running
          - success
          - failed
          - partial
        started_at:
          type:
          - string
          - 'null'
          format: date-time
        completed_at:
          type:
          - string
          - 'null'
          format: date-time
        duration_seconds:
          type:
          - integer
          - 'null'
        total_vms_found:
          type: integer
        vms_added:
          type: integer
        vms_updated:
          type: integer
        vms_excluded:
          type: integer
        vms_failed:
          type: integer
        vms_skipped:
          type: integer
          description: VMs discovery refused to import (another credential owns the UUID) -- distinct
            from failed/excluded.
        vmware_vms_found:
          type: integer
        cloudstack_vms_found:
          type: integer
        average_vm_discovery_time_ms:
          type:
          - integer
          - 'null'
        total_api_calls:
          type: integer
        excluded_appliances:
          type:
          - string
          - 'null'
          description: JSON array (as a string) of appliance-excluded VMs.
        error_count:
          type: integer
        error_summary:
          type:
          - string
          - 'null'
          description: JSON array (as a string) of RAW error text -- may embed source host/path detail.
        triggered_by:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required:
      - id
      - schedule_id
      - execution_type
      - site_id
      - sites_included
      - status
      - started_at
      - completed_at
      - duration_seconds
      - total_vms_found
      - vms_added
      - vms_updated
      - vms_excluded
      - vms_failed
      - vms_skipped
      - vmware_vms_found
      - cloudstack_vms_found
      - average_vm_discovery_time_ms
      - total_api_calls
      - excluded_appliances
      - error_count
      - error_summary
      - triggered_by
      - created_at
      - updated_at
    ErrorResponse:
      type: object
      description: The standard JSON error envelope. Most families return this; a few operations return
        a plain-text body instead, documented as text/plain on the operation.
      properties:
        error:
          type: string
          description: Human-readable message.
          example: Missing required authentication fields
        details:
          type: string
          description: Field or context, when available.
        timestamp:
          type: string
          format: date-time
      required:
      - error
    ExcludedAppliance:
      type: object
      properties:
        appliance_id:
          type: string
        vm_id:
          type: string
        vm_name:
          type: string
        platform:
          type: string
        reason:
          type: string
      required:
      - appliance_id
      - vm_id
      - vm_name
      - platform
      - reason
    ExecuteDiscoveryRequest:
      type: object
      description: Trigger a discovery run. Only `triggered_by` is required.
      properties:
        execution_type:
          type: string
          description: Optional label (defaults to `manual`).
        site_id:
          type: string
          description: Optional target site; the literal `global` fans out estate-wide (staff-only). Omit
            for the orchestrator default.
        discover_vmware:
          type: boolean
          description: Default true.
        discover_cloudstack:
          type: boolean
          description: Default true.
        triggered_by:
          type: string
          description: Required -- free-text actor label.
      required:
      - triggered_by
    ExecuteDiscoveryResponse:
      type: object
      properties:
        execution_id:
          type: string
          description: Poll this via GET /discovery/executions/{id}.
        status:
          type: string
          description: '`running` (the run is asynchronous; this 200 is an accept, not a completion).'
        execution_type:
          type: string
        triggered_by:
          type: string
        site_id:
          type: string
          description: Present only when a `site_id` was supplied in the request.
      required:
      - execution_id
      - status
      - execution_type
      - triggered_by
    FeatureEntitlements:
      type: object
      properties:
        multi_tenant:
          type: boolean
        remote_diagnostics:
          type: boolean
      description: Always present; a false value means 'not entitled', never 'unknown'.
      required:
      - multi_tenant
      - remote_diagnostics
    LicenceAssignment:
      type: object
      properties:
        id:
          type: string
        state:
          type: string
        product_family:
          type: string
        licence_basis:
          type: string
        assignment_policy:
          type: string
        source_workload_key:
          type: string
        vm_context_id:
          type:
          - string
          - 'null'
        workload_lineage_id:
          type: string
        first_successful_replication_at:
          type:
          - string
          - 'null'
          format: date-time
        release_started_at:
          type:
          - string
          - 'null'
          format: date-time
        release_at:
          type:
          - string
          - 'null'
          format: date-time
        released_at:
          type:
          - string
          - 'null'
          format: date-time
        policy_trigger_sha_id:
          type:
          - string
          - 'null'
        licence_unit_id:
          type:
          - string
          - 'null'
        committed_at:
          type:
          - string
          - 'null'
          format: date-time
      description: A workload licence assignment. Pointer fields serialize as `null` (present, not omitted).
      required:
      - id
      - state
      - product_family
      - licence_basis
      - assignment_policy
      - source_workload_key
      - vm_context_id
      - workload_lineage_id
      - first_successful_replication_at
      - release_started_at
      - release_at
      - released_at
      - policy_trigger_sha_id
      - licence_unit_id
      - committed_at
    LicenceCapabilityFlags:
      type: object
      properties:
        backup:
          type: boolean
        replication_same_hypervisor:
          type: boolean
        replication_cross_hypervisor:
          type: boolean
        restore_same_hypervisor:
          type: boolean
        restore_cross_hypervisor:
          type: boolean
        test_failover_cross_hypervisor:
          type: boolean
        failover_cross_hypervisor:
          type: boolean
        reverse_replication_cross_hypervisor:
          type: boolean
        migration_commit_cross_hypervisor:
          type: boolean
        migration_workflow_only:
          type: boolean
          description: Cross-hypervisor rights come ONLY from a one-time MIG pack, not from the edition.
        currency_state:
          type: string
          description: Worst check-in position across active documents; omitted when none carries the
            obligation.
        check_in_by:
          type: string
          description: Earliest check-in deadline among them.
        reconciliation_required:
          type: boolean
          description: True while a capacity reconciliation is OPEN and new protection is refused.
      required:
      - backup
      - replication_same_hypervisor
      - replication_cross_hypervisor
      - restore_same_hypervisor
      - restore_cross_hypervisor
      - test_failover_cross_hypervisor
      - failover_cross_hypervisor
      - reverse_replication_cross_hypervisor
      - migration_commit_cross_hypervisor
      - migration_workflow_only
      description: ADVISORY selection-time capability flags, appliance-wide. An operation is permitted
        when ANY active document allows it under that document's own term state. Execution paths re-check
        server-side, so never treat a true flag as authorization — it is what the provider may OFFER,
        not what a given call will be allowed to do.
    LicenceCapacityHistoryPoint:
      type: object
      properties:
        date:
          type: string
          description: UTC calendar day, `YYYY-MM-DD`.
        in_use:
          type: integer
          description: End-of-day assignment-tracked in-use count.
      required:
      - date
      - in_use
    LicenceCapacityTimeline:
      type: object
      properties:
        history:
          type: array
          items:
            $ref: '#/components/schemas/LicenceCapacityHistoryPoint'
          description: Reconstructed from the append-only assignment event ledger over a trailing 90-day
            window, ascending. EMPTY when the ledger is insufficient to reconstruct it — the reconstruction
            is never fabricated, so an empty history means 'not reconstructible', not 'nothing happened'.
        scheduled_releases:
          type: array
          items:
            $ref: '#/components/schemas/LicenceScheduledReleaseDay'
          description: The RELEASE_PENDING `release_at` instants grouped by UTC date, ascending. Derived
            from the same rows as `holds`, so the two cannot disagree.
      required:
      - history
      - scheduled_releases
      description: Both arrays are always present; empty, never null.
    LicenceCompositionBlock:
      type: object
      properties:
        license_id:
          type: string
          description: The licence whose composition plan or refusal was blocked.
        code:
          type: string
          description: Machine-readable reason code as the composition-blocked event records it.
        reason:
          type: string
        blocked_license_ids:
          type: array
          items:
            type: string
          description: The licences the blocked plan would have retired.
      required:
      - license_id
      - code
      - reason
      description: A pool deliberately left un-converged. Each entry leaves the appliance over-granted
        by a slice until somebody acts, and none of it is visible in the licence rows themselves — every
        document involved is active and healthy-looking. Absent on a converged pool.
    LicenceDocumentStatus:
      type: object
      properties:
        license_id:
          type: string
        edition:
          type: string
        license_type:
          type: string
        state:
          type: string
          description: Stored VERBATIM from the licence server; not a pinned appliance vocabulary.
        status:
          type: string
        family:
          type: string
          description: '`VMP` or `MIG`.'
        committed:
          type: boolean
          description: True for a committed term (12 months, or the claim absent); false for flexible
            (1).
        commitment_months:
          type: integer
        starts_at:
          type: string
        expires_at:
          type: string
        grace_until:
          type: string
        support_until:
          type: string
        max_protected_vms:
          type: integer
          description: This document's capacity slice.
        bound_protected_vms:
          type: integer
          description: Active ledger rows bound to this document.
        held_capacity:
          type: integer
          description: Capacity-holding assignment rows invisible to the ledger — reservations and 90-day
            holds. Consumption is `bound_protected_vms + held_capacity`; reading either alone under-reports.
        pack_quantity:
          type: integer
          description: Units in this MIG pack. MIG-family rows only — OMITTED on VMP documents, never
            sent as null.
        consumed:
          type: integer
          description: MIG units consumed against this pack. MIG-family rows only — OMITTED on VMP documents,
            never sent as null.
        usable_for_new_admission:
          type: boolean
          description: 'False drops this document''s slice out of the ENFORCED capacity: term-expired,
            currency-lapsed, locally invalid or terminally rejected.'
        usable:
          type: boolean
          description: The same value under the display-contracted name.
        term_state:
          type: string
          description: '`ACTIVE`, `GRACE` or `RESTORE_ONLY`.'
        currency_state:
          type: string
          description: '`NONE`, `CURRENT`, `GRACE` or `LAPSED`.'
        check_in_by:
          type: string
        currency_alert:
          type: string
        restore_only:
          type: boolean
        failure_code:
          type: string
        local_verification:
          $ref: '#/components/schemas/VerificationStatus'
        central_verification:
          $ref: '#/components/schemas/VerificationStatus'
      required:
      - license_id
      - max_protected_vms
      - bound_protected_vms
      - held_capacity
      - committed
      - usable_for_new_admission
      - usable
      - restore_only
      - local_verification
      - central_verification
      description: One licence document's position. The top-level status fields describe the pool's primary
        binding document; this list is the whole estate. Optional string and integer fields are OMITTED
        when empty rather than serialized as null.
    LicenceHold:
      type: object
      properties:
        assignment_id:
          type: string
          description: Stable key; use it against /api/v1/licensing/assignments/{id}/events.
        vm_context_id:
          type: string
          description: The workload's operational anchor. Stable enough to join on, but the assignment
            column it comes from is nullable and may change across DR stages — an empty string means the
            assignment row carries none. `source_workload_key` and `workload_lineage_id` on the assignment
            itself are the durable lineage keys.
        vm_name:
          type:
          - string
          - 'null'
          description: Joined from `vm_contexts` where the context still exists; `null` otherwise, which
            a released workload's deleted context legitimately is. Display only.
        tenant_id:
          type:
          - string
          - 'null'
          description: The assignment row's own `tenant_id` — display/audit grouping by its own schema
            comment, never an authorization input. `null` when the row carries none.
        stage:
          type: string
          description: '`ARMED` or `RELEASE_PENDING`.'
        armed_reason:
          type:
          - string
          - 'null'
        armed_at:
          type:
          - string
          - 'null'
          format: date-time
          description: RFC3339 UTC.
        release_at:
          type:
          - string
          - 'null'
          format: date-time
          description: RFC3339 UTC instant the hold lapses and the capacity returns to the pool.
      required:
      - assignment_id
      - vm_context_id
      - vm_name
      - tenant_id
      - stage
      - armed_reason
      - armed_at
      - release_at
      description: One armed or release-pending hold. Sorted by `assignment_id`.
    LicenceInsights:
      type: object
      properties:
        tenants:
          type: array
          items:
            $ref: '#/components/schemas/LicenceTenantPosition'
        holds:
          type: array
          items:
            $ref: '#/components/schemas/LicenceHold'
        capacity_timeline:
          $ref: '#/components/schemas/LicenceCapacityTimeline'
      required:
      - tenants
      - holds
      - capacity_timeline
      description: 'The consumption view: where the estate''s capacity has gone and when it comes back.
        This is the only place the API attributes licence consumption to a tenant.


        BEST-EFFORT BY DESIGN. The whole object is OMITTED from the status payload when its build fails,
        so that a reporting join can never take down the licence figures it sits beside. Absence is therefore
        not ''no consumption'' — treat an absent `insights` as unknown and retry, never as zero. All three
        members are present whenever the object is; arrays are empty rather than null.'
    LicenceMIGPool:
      type: object
      properties:
        license_id:
          type: string
        pack_quantity:
          type: integer
          description: Units in the pack.
        consumed:
          type: integer
          description: Units permanently consumed.
        available:
          type: integer
          description: Units still grantable.
      required:
      - license_id
      - pack_quantity
      - consumed
      - available
      description: One MIG pack's position. MIG capacity is per licence and never pooled, so each pack
        is listed separately. Only GRANTABLE packs appear here; a pack that has turned unusable leaves
        this list but keeps its consumption history in `documents[]`.
    LicencePoolStatus:
      type: object
      properties:
        vmp_pool_capacity:
          type: integer
          description: 'ENFORCED capacity: the sum of `max_protected_vms` over the USABLE VMP documents
            — the figure admission and the reconciliation fuse measure against.'
        vmp_total_capacity:
          type: integer
          description: 'LICENSED display total: the same sum over active VMP documents excluding dead
            ones. Currency-lapsed and otherwise unusable documents are counted HERE but not in `vmp_pool_capacity`;
            an IN-GRACE document is still usable for new admission and so counts in both, so the two differ
            exactly by what is licensed but not currently enforceable. Never present the two under one
            label.'
        vmp_documents:
          type: integer
          description: Count of the licensed (non-dead) VMP documents behind `vmp_total_capacity`.
        current_protected_vms:
          type: integer
          description: Live protected-VM ledger rows, provider-wide.
        mig_pools:
          type: array
          items:
            $ref: '#/components/schemas/LicenceMIGPool'
      required:
      - vmp_pool_capacity
      - vmp_total_capacity
      - vmp_documents
      - current_protected_vms
      description: 'Composed capacity across every active licence document on the appliance. Provider-wide
        with NO tenant or site attribution: capacity is licensed to the appliance, not to a tenant, and
        nothing in the pool carries a tenant key. Use `insights.tenants` for the consumption side of that
        question, and read its attribution caveats first.'
    LicenceScheduledReleaseDay:
      type: object
      properties:
        date:
          type: string
          description: UTC calendar day, `YYYY-MM-DD`.
        count:
          type: integer
          description: Holds scheduled to lapse on that day.
      required:
      - date
      - count
    LicenceStatus:
      type: object
      properties:
        licensed:
          type: boolean
        status:
          type: string
          description: '`UNLICENSED`, `VALID`, `WARNING`, `INVALID`, or `OVER_LIMIT`.'
        license_id:
          type: string
        edition:
          type: string
        license_type:
          type: string
        state:
          type: string
          description: Stored verbatim from the licence server (not a pinned vocabulary).
        key_id:
          type: string
          description: Signing-key id (not a secret).
        starts_at:
          type: string
        expires_at:
          type: string
        grace_until:
          type: string
        support_until:
          type: string
        max_protected_vms:
          type: integer
          description: Licensed protected-VM capacity of the pool's primary binding document. For the
            composed estate figure read `pool.vmp_pool_capacity` (enforced) and `pool.vmp_total_capacity`
            (licensed).
        current_protected_vms:
          type: integer
          description: Live protected-VM ledger rows, provider-wide across every tenant. This is CONSUMPTION
            as admission measures it and carries no tenant attribution of its own; `insights.tenants[].workloads`
            is the attributed breakdown, with the caveats recorded there. It does NOT include capacity
            held by reservations and 90-day holds — see `documents[].held_capacity` and `insights.holds`.
        over_limit:
          type: boolean
        failure_code:
          type: string
        last_updated_at:
          type:
          - string
          - 'null'
          format: date-time
        local_verification:
          $ref: '#/components/schemas/VerificationStatus'
        central_verification:
          $ref: '#/components/schemas/VerificationStatus'
        term_state:
          type: string
          description: '`ACTIVE`, `GRACE`, or `RESTORE_ONLY` (backend-computed).'
        currency_state:
          type: string
          description: '`NONE`, `CURRENT`, `GRACE`, or `LAPSED`.'
        check_in_by:
          type: string
        currency_alert:
          type: string
        restore_only:
          type: boolean
        capabilities:
          $ref: '#/components/schemas/LicenceCapabilityFlags'
        reconciliation:
          $ref: '#/components/schemas/LicenceStatusReconciliation'
        documents:
          type: array
          items:
            $ref: '#/components/schemas/LicenceDocumentStatus'
          description: Every active licence document on the appliance; omitted when unlicensed.
        pool:
          $ref: '#/components/schemas/LicencePoolStatus'
        composition_blocked:
          type: array
          items:
            $ref: '#/components/schemas/LicenceCompositionBlock'
        insights:
          $ref: '#/components/schemas/LicenceInsights'
        feature_entitlements:
          $ref: '#/components/schemas/FeatureEntitlements'
      description: 'Licence status. Shared by GET /status, POST /activate and POST /verify-now. Every
        field is server-computed and read-only.


        PROVIDER-WIDE. Every figure here describes the appliance as a whole. The only tenant-attributed
        member is `insights.tenants`; read its attribution rules before using it, because the workload
        count and the hold counts on one row are grouped by two different keys.


        Optional members (`insights`, `pool`, `reconciliation`, `documents`, `composition_blocked`, `capabilities`)
        are OMITTED, not null, when they do not apply — and `insights` is additionally omitted when its
        build fails, so absence there means unknown rather than empty.'
      required:
      - licensed
      - status
      - max_protected_vms
      - current_protected_vms
      - over_limit
      - local_verification
      - central_verification
      - restore_only
      - feature_entitlements
    LicenceStatusReconciliation:
      type: object
      properties:
        open:
          type: boolean
          description: Whether a capacity reconciliation window is open.
        deadline:
          type: string
          format: date-time
          description: RFC3339 selection instant; present only while open.
        over_by:
          type: integer
          description: Allocation minus licensed capacity, holds included.
        actionable_over_by:
          type: integer
          description: The overage excluding unexpired release-pending holds — what the provider can still
            act on. `0` while `over_by > 0` means the overage is holds-only and clears on the decay schedule
            below.
        unexpired_holds:
          type: integer
          description: Already-released capacity still decaying.
        hold_decay:
          type: array
          items:
            type: string
            format: date-time
          description: When each held unit returns to the pool, ascending RFC3339.
        earliest_hold_release:
          type: string
          format: date-time
          description: First entry of `hold_decay`.
        capacity_returns_at:
          type: string
          format: date-time
          description: 'When the ESTATE stops being over capacity. Estate-scoped, and therefore NOT the
            date an admission refusal carries — a refusal is short its own new workloads on top of the
            estate''s overage and answers under its own name. ABSENT means one of two different things:
            the estate is already within capacity, or the overage outruns the holds so decay alone can
            never clear it. Read it against `over_by`: absent with `over_by > 0` is the second case.'
      required:
      - open
      - over_by
      - actionable_over_by
      - unexpired_holds
      description: The capacity position embedded in the status payload. OMITTED entirely when the capacity
        axis is quiet. This is a DIFFERENT shape from GET /api/v1/licensing/reconciliation — do not decode
        one as the other.
    LicenceTenantPosition:
      type: object
      properties:
        tenant_id:
          type: string
          description: 'The tenant this position belongs to, or `""` for the UNTENANTED group. ATTRIBUTION:
            `workloads` is grouped by resolving each live protected-VM ledger row through `sha_license_protected_vms.vm_context_id`
            -> `vm_contexts.site_id` -> `sites.tenant_id`, a keyed relational chain and never a name match.
            `armed` and `release_pending` are grouped by a DIFFERENT key: the `tenant_id` column carried
            on the assignment row itself, whose own schema comment records it as display/audit grouping
            and never an authorization input. The two counts on one row can therefore disagree for a workload
            whose assignment row carries a different tenant id or none; do not treat them as one join.'
        tenant_name:
          type:
          - string
          - 'null'
          description: Display name resolved from the `tenants` table where a row exists, `null` otherwise.
            Display only — never a join key and never an authorization input.
        workloads:
          type: integer
          description: Live (unreleased) protected-VM ledger rows attributed to this tenant.
        armed:
          type: integer
          description: Assignments with a hold armed but not yet releasing.
        release_pending:
          type: integer
          description: Assignments in RELEASE_PENDING, i.e. inside the holding period.
      required:
      - tenant_id
      - tenant_name
      - workloads
      - armed
      - release_pending
      description: 'One tenant''s licence position. Sorted by `tenant_id`, so the untenanted group sorts
        first. Read the WHOLE array before concluding anything about attribution: where the resolution
        chain is absent (a schema without `vm_contexts.site_id` or without `sites.tenant_id`, which includes
        every single-tenant estate) EVERY workload lands in the `""` group. An all-empty-tenant response
        means attribution is unavailable on this appliance, NOT that nothing is tenanted.'
    ProviderToken:
      type: object
      description: The safe projection of a provider credential. It never carries the token value or its
        hash.
      properties:
        id:
          type: string
          description: Credential id.
          example: ptok-3f9c2a7b41d84e6fa0b5c8d213e47f96
        name:
          type: string
          description: Operator-facing label, and the audit actor — name it after the automation, not
            after a person.
        description:
          type:
          - string
          - 'null'
          description: Free-text note.
        token_prefix:
          type: string
          description: Lookup prefix, never a secret. Use it to recognise a credential found in a configuration
            file.
        scope:
          type: string
          enum:
          - provider
          description: The explicit scope claim. Provider reach comes from this value, never from an empty
            site list.
        permissions:
          type: array
          items:
            type: string
          description: Granted permissions, intersected with the minting administrator's own at mint time.
        never_expires:
          type: boolean
          description: 'True when the credential was deliberately minted without an expiry. Enforced at
            authentication as well as at mint: a stored row carrying neither an expiry nor this flag does
            not authenticate.'
        expires_at:
          type:
          - string
          - 'null'
          format: date-time
          description: Expiry, or null when `never_expires` is true.
        revoked_at:
          type:
          - string
          - 'null'
          format: date-time
        revoked_by:
          type:
          - string
          - 'null'
          description: Who revoked it.
        revoked_reason:
          type:
          - string
          - 'null'
        last_used_at:
          type:
          - string
          - 'null'
          format: date-time
          description: Last successful authentication. Best-effort observability, not an authorization
            input.
        last_used_ip:
          type:
          - string
          - 'null'
          description: Source address of the last successful authentication, where the deployment records
            one.
        created_by_user_id:
          type:
          - string
          - 'null'
          description: The administrator who minted it. Null once that account is deleted — the credential
            deliberately outlives the account so it becomes visible for review rather than vanishing.
        created_at:
          type: string
          format: date-time
      required:
      - id
      - name
      - token_prefix
      - scope
      - permissions
      - never_expires
      - created_at
    ProviderTokenListResponse:
      type: object
      properties:
        provider_tokens:
          type: array
          items:
            $ref: '#/components/schemas/ProviderToken'
        total:
          type: integer
          description: Number of credentials returned.
      required:
      - provider_tokens
      - total
    ProviderTokenMintRequest:
      type: object
      description: Exactly one of `expires_in_days` and `never_expires` must be supplied.
      properties:
        name:
          type: string
          description: Required. The audit actor — name it after the automation.
          example: provider-example migration orchestrator
        description:
          type: string
          description: Optional free-text note.
        permissions:
          type: array
          items:
            type: string
          description: Permissions to grant, intersected with the minting administrator's own. At least
            one must survive the intersection.
        expires_in_days:
          type: integer
          minimum: 1
          description: Lifetime in days. Mutually exclusive with `never_expires`.
        never_expires:
          type: boolean
          description: Deliberately mint a permanent credential. Mutually exclusive with `expires_in_days`;
            omitting both is refused.
      required:
      - name
      - permissions
    ProviderTokenMintResponse:
      type: object
      properties:
        token:
          type: string
          description: The clear credential, returned exactly once and never retrievable again.
        warning:
          type: string
          description: Handling instruction shown with the value.
        provider_token:
          $ref: '#/components/schemas/ProviderToken'
      required:
      - token
      - warning
      - provider_token
    ProviderTokenResponse:
      type: object
      properties:
        provider_token:
          $ref: '#/components/schemas/ProviderToken'
      required:
      - provider_token
    ProviderTokenRevokeRequest:
      type: object
      properties:
        reason:
          type: string
          description: Required. The record of why the credential was killed.
          example: orchestrator decommissioned
      required:
      - reason
    ProviderTokenRevokeResponse:
      type: object
      properties:
        status:
          type: string
          enum:
          - revoked
      required:
      - status
    SetUserSitesRequest:
      type: object
      required:
      - site_ids
      properties:
        site_ids:
          type: array
          items:
            type: string
          description: Required. An empty array removes all site access. Blank entries are trimmed away;
            unknown ids are rejected with 400.
    SetUserSitesResult:
      type: object
      required:
      - success
      - user_id
      - site_ids
      properties:
        success:
          type: boolean
        user_id:
          type: string
        site_ids:
          type: array
          items:
            type: string
          description: The cleaned, applied list (after trim/dedup). Always an array.
    Site:
      type: object
      description: 'A site as returned by the DETAIL and update endpoints: carries tenant_id (null when
        ungrouped) but no status, and a raw appliance_count.'
      required:
      - id
      - name
      - site_type
      - is_sna_pool
      - assume_file_based_storage
      - appliance_count
      - created_at
      - updated_at
      - description
      - location
      - sna_delegate_site_id
      - tenant_id
      properties:
        id:
          type: string
        name:
          type: string
        description:
          type:
          - string
          - 'null'
        location:
          type:
          - string
          - 'null'
        site_type:
          type: string
          enum:
          - production
          - dr
          - branch
          - test
          - dev
        is_sna_pool:
          type: boolean
        assume_file_based_storage:
          type: boolean
        sna_delegate_site_id:
          type:
          - string
          - 'null'
        appliance_count:
          type: integer
          description: Raw count (not delegation-aware).
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        tenant_id:
          type:
          - string
          - 'null'
          description: The tenant this site is grouped under, or null when ungrouped. Grouping only —
            never an authorisation input; site_id is the isolation anchor.
    SiteCascadeDelete:
      type: object
      required:
      - site_id
      - label
      - count
      properties:
        site_id:
          type: string
        label:
          type: string
          description: The operator-facing noun for what goes (e.g. `site-scoped vault credentials`),
            not a table name.
        count:
          type: integer
        cleared:
          type: boolean
          description: True when the reference is NULLed rather than the rows deleted.
    SiteCreateRequest:
      type: object
      required:
      - name
      properties:
        name:
          type: string
          minLength: 1
          description: A blank name is rejected with 400.
        description:
          type: string
        location:
          type: string
    SiteCreateResult:
      type: object
      description: The minimal shape returned by create (7 fields).
      required:
      - id
      - name
      - description
      - location
      - appliance_count
      - created_at
      - updated_at
      properties:
        id:
          type: string
        name:
          type: string
        description:
          type: string
          description: Defaults to an empty string when not supplied.
        location:
          type: string
          description: Defaults to an empty string when not supplied.
        appliance_count:
          type: integer
          description: Always 0 for a new site.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    SiteDeleteResult:
      type: object
      required:
      - message
      properties:
        message:
          type: string
          example: Site deleted successfully
    SiteListItem:
      type: object
      description: A site as returned by the LIST endpoint (carries tenant_id and computed status).
      required:
      - id
      - name
      - site_type
      - status
      - is_sna_pool
      - assume_file_based_storage
      - appliance_count
      - created_at
      - updated_at
      - description
      - location
      - sna_delegate_site_id
      - tenant_id
      properties:
        id:
          type: string
        name:
          type: string
        description:
          type:
          - string
          - 'null'
        location:
          type:
          - string
          - 'null'
        site_type:
          type: string
          enum:
          - production
          - dr
          - branch
          - test
          - dev
        status:
          type: string
          enum:
          - offline
          - healthy
          - degraded
          description: Computed health (not the stored status column). `offline` is the default/sentinel.
        is_sna_pool:
          type: boolean
        assume_file_based_storage:
          type: boolean
        sna_delegate_site_id:
          type:
          - string
          - 'null'
        tenant_id:
          type:
          - string
          - 'null'
        appliance_count:
          type: integer
          description: Delegation-aware count.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    SiteListResponse:
      type: object
      required:
      - sites
      - total
      properties:
        sites:
          type: array
          items:
            $ref: '#/components/schemas/SiteListItem'
          description: Never null (empty at most).
        total:
          type: integer
    SuccessResponse:
      type: object
      description: A minimal success acknowledgement.
      properties:
        success:
          type: boolean
      required:
      - success
    TenantApplianceFate:
      type: object
      required:
      - id
      properties:
        id:
          type: string
        name:
          type: string
        site_id:
          type: string
    TenantApplyResult:
      type: object
      required:
      - tenant_id
      - sites_created
      - users_created
      - bindings_created
      - credentials
      properties:
        tenant_id:
          type: string
        sites_created:
          type: integer
        users_created:
          type: integer
        bindings_created:
          type: integer
        credentials:
          type: array
          description: Generated initial passwords, returned EXACTLY once. The appliance stores only bcrypt
            hashes; there is no way to retrieve these later.
          items:
            type: object
            required:
            - email
            - password
            properties:
              email:
                type: string
              password:
                type: string
        replayed:
          type: boolean
          description: True when this response replays an earlier create with the same Idempotency-Key.
            Nothing was written and credentials are empty — the originals are unrecoverable.
        sites_ungrouped:
          type: integer
          description: Sites an edit removed from the tenant.
        logins_disabled:
          type: integer
          description: Logins switched off by a suspend's projection.
        logins_enabled:
          type: integer
          description: Logins switched back on by a resume's projection.
        sites_adopted:
          type: integer
          description: Existing sites the edit re-homed into the tenant.
    TenantConflictResponse:
      type: object
      required:
      - error
      properties:
        error:
          type: string
        conflicts:
          type: array
          items:
            $ref: '#/components/schemas/TenantProvisionProblem'
          description: Each conflict names the field it blocks on (`remove_sites` for a tenant delete),
            the site, the protected VMs and the remedy.
        appliance_id:
          type: string
          description: 'Present on the in-flight-work refusal: the appliance that still has work running.'
        impact:
          type: string
          description: 'Present on the in-flight-work refusal: what deleting the appliance now would interrupt.'
    TenantCreateRequest:
      type: object
      required:
      - name
      - sites
      - users
      properties:
        tenant_id:
          type: string
          description: Optional; omitted generates a UUID. Supplying it makes a re-run of the same request
            idempotent.
        name:
          type: string
        description:
          type: string
        sites:
          type: array
          items:
            $ref: '#/components/schemas/TenantSiteSpec'
        users:
          type: array
          items:
            $ref: '#/components/schemas/TenantUserSpec'
    TenantCredentialDetail:
      type: object
      description: 'A credential''s IDENTITY as seen from the tenant surface. Never carries credential_data:
        the encrypted payload is reachable only through the vault surface, which gates unmasking separately.'
      properties:
        id:
          type: string
        site_id:
          type: string
          description: The tenant site this credential is scoped to.
        credential_type:
          type: string
          enum:
          - vmware
          - cloudstack
          - nutanix
        credential_name:
          type: string
        description:
          type: string
          description: The purpose label supplied at write time, if any.
        is_default:
          type: boolean
          description: Preferred credential of its type for this site.
      required:
      - id
      - site_id
      - credential_type
      - credential_name
      - is_default
    TenantCredentialFate:
      type: object
      required:
      - id
      properties:
        id:
          type: string
        name:
          type: string
        site_id:
          type: string
      description: A stored credential by identity only — never a secret.
    TenantDeleteOutcome:
      description: 'One status, two shapes: a real delete answers with `TenantDeleteResult`; `?dry_run=true`
        answers with `TenantDeletePlan` (recognisable by `ungroup_sites` and `delete_logins`, which the
        result never carries).'
      anyOf:
      - $ref: '#/components/schemas/TenantDeleteResult'
      - $ref: '#/components/schemas/TenantDeletePlan'
    TenantDeletePlan:
      type: object
      required:
      - tenant_id
      - ungroup_sites
      - delete_logins
      - keep_logins
      properties:
        tenant_id:
          type: string
        name:
          type: string
        ungroup_sites:
          type: array
          items:
            type: string
          description: Every site the tenant owns. The site rows and their discovered inventory survive
            the delete, ungrouped (tenant_id null); only the grouping, the user bindings and the pool
            delegation go. Deleting the sites afterwards is what removes their inventory.
        detach_pool_site_ids:
          type: array
          items:
            type: string
        delete_logins:
          type: array
          items:
            $ref: '#/components/schemas/TenantLoginFate'
          description: Accounts whose entire binding estate lies inside this tenant. Each is deleted through
            the users surface, with its own user_deleted audit row and session revocation.
        keep_logins:
          type: array
          items:
            $ref: '#/components/schemas/TenantLoginFate'
          description: Accounts also bound outside the tenant. They survive; their in-tenant bindings
            fall away with the ungroups.
        conflicts:
          type: array
          items:
            $ref: '#/components/schemas/TenantProvisionProblem'
          description: Present while the delete is refused. Each names the site (`field` is `remove_sites`),
            the protected VMs it still holds and the remedy; a real delete with conflicts answers 409
            with the same list.
        cascade_deletes:
          type: array
          items:
            $ref: '#/components/schemas/SiteCascadeDelete'
          description: What the cascade destroys per site (site-scoped vault credentials, vCenter credentials,
            CloudStack sources, appliances). Planned per non-conflicted site, so it appears beside `conflicts[]`
            when other sites are clear.
        delete_appliances:
          type: array
          items:
            $ref: '#/components/schemas/TenantApplianceFate'
        delete_credentials:
          type: array
          items:
            $ref: '#/components/schemas/TenantCredentialFate'
        revoke_site_tokens:
          type: array
          items:
            $ref: '#/components/schemas/TenantSiteTokenFate'
          description: Active site API tokens whose scope touches any of the tenant's sites; the delete
            revokes them.
    TenantDeleteResult:
      type: object
      required:
      - tenant_id
      - sites_ungrouped
      - logins_deleted
      - logins_kept
      properties:
        tenant_id:
          type: string
        name:
          type: string
        sites_ungrouped:
          type: array
          items:
            type: string
        pool_detached:
          type: array
          items:
            type: string
          description: Sites whose SNA-pool delegation was detached.
        logins_deleted:
          type: integer
          description: Logins whose whole binding estate lay inside the tenant, deleted through the users
            surface.
        logins_kept:
          type: integer
          description: Logins also bound outside the tenant; kept, minus their in-tenant bindings.
        cascaded_site_ids:
          type: array
          items:
            type: string
          description: Sites whose site-scoped configuration was destroyed by the cascade.
        cascade_deletes:
          type: array
          items:
            $ref: '#/components/schemas/SiteCascadeDelete'
          description: What the cascade destroyed, per site.
        appliances_deleted:
          type: integer
        credentials_deleted:
          type: integer
        site_tokens_revoked:
          type: integer
          description: Site API tokens revoked because their scope touched the tenant's sites.
    TenantDetail:
      type: object
      required:
      - id
      - name
      - status
      - sites
      - users
      - credentials
      properties:
        id:
          type: string
        name:
          type: string
        description:
          type: string
        status:
          type: string
          enum:
          - active
          - suspended
        sites:
          type: array
          items:
            $ref: '#/components/schemas/TenantSiteDetail'
        users:
          type: array
          items:
            $ref: '#/components/schemas/TenantUserDetail'
        credentials:
          type: array
          items:
            $ref: '#/components/schemas/TenantCredentialDetail'
          description: Active site-scoped credentials for the tenant's sites, identity only. ALWAYS present,
            possibly empty — an empty array is the signal that the tenant cannot reach any hypervisor
            yet, which is the state a tenant provisioned without the credentials step is left in.
    TenantErrorResponse:
      type: object
      required:
      - error
      properties:
        error:
          type: string
    TenantListEntry:
      type: object
      required:
      - id
      - name
      - status
      - site_count
      - user_count
      properties:
        id:
          type: string
        name:
          type: string
        description:
          type: string
        status:
          type: string
          enum:
          - active
          - suspended
        site_count:
          type: integer
        user_count:
          type: integer
          description: Distinct logins bound to any of the tenant's sites.
    TenantListResponse:
      type: object
      required:
      - tenants
      - total
      properties:
        tenants:
          type: array
          items:
            $ref: '#/components/schemas/TenantListEntry'
        total:
          type: integer
    TenantLoginFate:
      type: object
      required:
      - user_id
      - email
      properties:
        user_id:
          type: string
        email:
          type: string
    TenantProvisionProblem:
      type: object
      required:
      - field
      - reason
      properties:
        field:
          type: string
          description: '`remove_sites` for a site that still holds protected VMs; `tenancy` or `tenant_id`
            for a tenancy-preflight failure.'
        reason:
          type: string
    TenantSiteDetail:
      type: object
      required:
      - site_id
      - name
      - attached_to_sna_pool
      properties:
        site_id:
          type: string
        name:
          type: string
        attached_to_sna_pool:
          type: boolean
    TenantSiteSpec:
      type: object
      required:
      - site_id
      - name
      properties:
        site_id:
          type: string
          description: 1-63 characters of [A-Za-z0-9._-] starting alphanumeric. Lowercased server-side;
            the reserved SNA pool id is refused.
        name:
          type: string
        attach_to_sna_pool:
          type: boolean
          description: 'An INTENT, not a site id: the server resolves it to the reserved pool site after
            validating the pool exists. Callers never name the delegate target.'
    TenantSiteTokenFate:
      type: object
      required:
      - id
      - site_ids
      properties:
        id:
          type: string
        name:
          type: string
        site_ids:
          type: array
          items:
            type: string
          description: The token's full site scope (empty when it could not be decoded — such a token
            is revoked on the safe side).
      description: A site API token by identity; never its value.
    TenantUserDetail:
      type: object
      required:
      - email
      - roles
      - site_ids
      properties:
        user_id:
          type: string
          description: The user's id — used by the impersonation start route.
        email:
          type: string
        full_name:
          type: string
        roles:
          type: array
          items:
            type: string
        site_ids:
          type: array
          items:
            type: string
    TenantUserSpec:
      type: object
      required:
      - email
      properties:
        email:
          type: string
        full_name:
          type: string
        role:
          type: string
          enum:
          - tenant-replication-admin
          - tenant-replication-operator
          - tenant-replication-viewer
          description: Must be in the tenant-replication-* family. The similarly-named SCA roles tenant-viewer/tenant-operator
            are also tenant-assignable but carry estate-wide permissions, and are refused here.
    UnifiedDiscoveryRequest:
      type: object
      description: 'Request for unified discovery. Supply credentials ONLY by vault reference: `vault_credential_id`
        (required for vmware/nutanix, enforced server-side -> 400 if missing) or `cloudstack_source_id`
        (cloudstack). Legacy inline VMware credential fields (vcenter_host/user/pass, datacenter, credential_id)
        that older discovery paths accepted are IGNORED here -- and `credential_id` is actively REJECTED
        with 400 -- so they are intentionally not part of this contract (do not send a plaintext password).'
      properties:
        platform:
          type: string
          enum:
          - vmware
          - cloudstack
          - nutanix
          description: Required. The source platform.
        vault_credential_id:
          type: string
          description: Vault reference to the VMware/Nutanix credential (required for those platforms;
            the secret is loaded server-side, never sent).
        cloudstack_source_id:
          type: integer
          description: The configured CloudStack source row (cloudstack platform).
        repository_id:
          type: string
          description: The intended target backup repository. (Tagged required upstream but NOT enforced
            at the HTTP layer -- effectively optional here.)
        filter:
          type: string
          description: Case-insensitive VM-name substring filter.
        selected_vms:
          type: array
          items:
            type: string
          description: Restrict discovery to these VM names.
        save_to_db:
          type: boolean
          description: When true, persist discovered VMs to `vm_contexts` (default false); UUID collisions
            are skipped (see `skipped_vms`).
      required:
      - platform
    UnifiedDiscoveryResponse:
      type: object
      properties:
        platform:
          type: string
        vms:
          type: array
          items:
            $ref: '#/components/schemas/UnifiedVMInfo'
          description: Always an array (empty, never null). VMs DISCOVERED at the source -- not necessarily
            imported (see `skipped_vms`).
        vm_count:
          type: integer
          description: Count discovered at the source (not the count imported).
        skipped_vms:
          type: array
          items:
            type: string
          description: VMs discovered but NOT imported because another credential already owns their platform
            UUID. Present (omitted when empty) only for a `save_to_db` run.
        skipped_vm_count:
          type: integer
          description: Count of `skipped_vms`; omitted when zero/absent.
        processing_time:
          type: integer
          format: int64
          description: Elapsed time in NANOSECONDS (Go `time.Duration` serialized as an int64, NOT a duration
            string).
        status:
          type: string
          description: '`success` on the 200 path.'
        message:
          type: string
      required:
      - platform
      - vms
      - vm_count
      - processing_time
      - status
      - message
    UnifiedDiskInfo:
      type: object
      description: 'Platform-agnostic disk info. NOTE: this struct has NO json tags, so its keys are Go
        PascalCase (unlike the snake_case siblings).'
      properties:
        DiskIndex:
          type: integer
        DiskID:
          type: string
          description: Platform-specific disk id.
        SizeGB:
          type: integer
          format: int64
        CapacityBytes:
          type: integer
          format: int64
        VMwareDatastore:
          type: string
          description: Source datastore (storage topology).
        VMwareUnitNumber:
          type: integer
        CloudStackVolumeID:
          type: string
        CloudStackDeviceID:
          type: string
        Label:
          type: string
        ProvisioningType:
          type: string
        Path:
          type: string
          description: Disk path (storage topology).
      required:
      - DiskIndex
      - DiskID
      - SizeGB
      - CapacityBytes
      - VMwareDatastore
      - VMwareUnitNumber
      - CloudStackVolumeID
      - CloudStackDeviceID
      - Label
      - ProvisioningType
      - Path
    UnifiedIPAddressInfo:
      type: object
      description: A reported IPv4 address. NO json tags -> Go PascalCase keys.
      properties:
        IPAddress:
          type: string
        PrefixLength:
          type: integer
        AddressType:
          type: string
        DHCPv4:
          type: boolean
        IsPrimary:
          type: boolean
        Source:
          type: string
      required:
      - IPAddress
      - PrefixLength
      - AddressType
      - DHCPv4
      - IsPrimary
      - Source
    UnifiedNetworkInfo:
      type: object
      description: 'Platform-agnostic network info. NOTE: NO json tags -> Go PascalCase keys. `Connected`
        is emitted as null when the provider does not report it; `IPAddresses`/`IPv4Addresses` are emitted
        as null when unset (nil slice).'
      properties:
        ProviderNICID:
          type: string
        IdentityKey:
          type: string
        DeviceIndex:
          type: integer
        Label:
          type: string
        AdapterType:
          type: string
          description: e.g. vmxnet3.
        NetworkID:
          type: string
        NetworkName:
          type: string
        MACAddress:
          type: string
          description: Guest MAC (estate/PII-adjacent).
        Connected:
          type:
          - boolean
          - 'null'
        IPAddress:
          type: string
          description: Primary guest IP (estate/PII-adjacent).
        IPAddresses:
          type:
          - array
          - 'null'
          items:
            type: string
          description: All reported guest IPs.
        IPv4Addresses:
          type:
          - array
          - 'null'
          items:
            $ref: '#/components/schemas/UnifiedIPAddressInfo'
          description: Detailed reported IPv4 addresses.
        IPAddressType:
          type: string
          description: reported | guest | primary | secondary.
        IPSource:
          type: string
          description: discovery | vmware_tools | cloudstack.
        Gateway:
          type: string
        Netmask:
          type: string
        IsDefault:
          type: boolean
        MetadataJSON:
          type: string
          description: Optional provider-specific JSON blob (string).
      required:
      - ProviderNICID
      - IdentityKey
      - DeviceIndex
      - Label
      - AdapterType
      - NetworkID
      - NetworkName
      - MACAddress
      - Connected
      - IPAddress
      - IPAddresses
      - IPv4Addresses
      - IPAddressType
      - IPSource
      - Gateway
      - Netmask
      - IsDefault
      - MetadataJSON
    UnifiedVMContextWithGroups:
      description: A VM inventory record with source health and machine-group memberships; the element
        type of `GET /vm-contexts`. Embeds VMContext + VMSourceHealth (flattened).
      allOf:
      - $ref: '#/components/schemas/VMContext'
      - $ref: '#/components/schemas/VMSourceHealth'
      - type: object
        properties:
          effective_storage_backend:
            type: string
            description: Resolved storage backend (override -> vault default -> source default). Omitted
              when nothing is set.
          primary_ip:
            type: string
            description: Omitted when unset.
          primary_network_name:
            type: string
            description: Omitted when unset.
          primary_mac:
            type: string
            description: Omitted when unset.
          nic_count:
            type: integer
          groups:
            type: array
            items:
              $ref: '#/components/schemas/VMContextGroupMembership'
            description: Always an array (empty, never null) -- built with make().
          group_count:
            type: integer
        required:
        - nic_count
        - groups
        - group_count
    UnifiedVMInfo:
      type: object
      description: A discovered VM in platform-agnostic form. The nested `disks`/`networks` element structs
        carry Go PascalCase keys (no json tags) -- see UnifiedDiskInfo / UnifiedNetworkInfo.
      properties:
        vm_name:
          type: string
        source_platform:
          type: string
        vmware_vm_id:
          type: string
          description: Present for VMware sources.
        cloudstack_vm_id:
          type: string
          description: Present for CloudStack sources.
        nutanix_vm_id:
          type: string
          description: Present for Nutanix sources.
        vm_path:
          type: string
        datacenter:
          type: string
        cpu_count:
          type: integer
        memory_mb:
          type: integer
        os_type:
          type: string
        power_state:
          type: string
        disk_count:
          type: integer
        disks:
          type: array
          items:
            $ref: '#/components/schemas/UnifiedDiskInfo'
          description: Omitted when absent.
        networks:
          type: array
          items:
            $ref: '#/components/schemas/UnifiedNetworkInfo'
          description: Omitted when absent.
      required:
      - vm_name
      - source_platform
      - vm_path
      - datacenter
      - cpu_count
      - memory_mb
      - os_type
      - power_state
      - disk_count
    UserCreateRequest:
      type: object
      required:
      - email
      - password
      properties:
        email:
          type: string
          format: email
          minLength: 1
          description: Rejected with 400 when blank.
        password:
          type: string
          format: password
          minLength: 1
          description: Rejected with 400 when empty.
        full_name:
          type: string
        roles:
          type: array
          items:
            type: string
          description: Role names; each must exist or the request is 400.
    UserCreateResult:
      type: object
      required:
      - id
      - email
      - status
      - created_at
      - full_name
      properties:
        id:
          type: string
        email:
          type: string
          format: email
        full_name:
          type:
          - string
          - 'null'
        status:
          type: string
          enum:
          - active
          - disabled
          - pending
          - locked
        created_at:
          type: string
          format: date-time
    UserListItem:
      type: object
      required:
      - id
      - email
      - status
      - roles
      - created_at
      - updated_at
      - full_name
      - last_login
      - permissions
      properties:
        id:
          type: string
        email:
          type: string
          format: email
        full_name:
          type:
          - string
          - 'null'
          description: Null when unset.
        status:
          type: string
          enum:
          - active
          - disabled
          - pending
          - locked
        roles:
          type: array
          items:
            type: string
          description: Role names. Always an array.
        permissions:
          type:
          - array
          - 'null'
          items:
            type: string
          description: Effective permission slugs. Null if they could not be resolved.
        last_login:
          type:
          - string
          - 'null'
          format: date-time
          description: Null if the user has never logged in.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    UserListResponse:
      type: object
      required:
      - users
      properties:
        users:
          type: array
          items:
            $ref: '#/components/schemas/UserListItem'
          description: Always an array.
    UserSitesResponse:
      type: object
      required:
      - user_id
      - site_ids
      properties:
        user_id:
          type: string
        site_ids:
          type: array
          items:
            type: string
          description: Bare site ids. Always an array (empty, never null).
    VMContext:
      type: object
      description: 'The unified VM inventory record (`vm_contexts` table), one per discovered VM. Embedded
        (Go anonymous embed -> flattened JSON) in `UnifiedVMContextWithGroups` and `VMContextDetailResponse`,
        and returned bare by the manage/unmanage routes. NULLABILITY: pointer fields WITHOUT `omitempty`
        are ALWAYS present and emitted as `null` when unset (marked required + nullable here); pointer
        fields WITH `omitempty` are omitted when nil (optional). `protection_enabled` is a schema default
        of 1 for every row and is NOT a live protection indicator. Fields echo internal estate topology
        (vcenter_host, vm_path, resource-pool paths, datacenter, cloudstack_instance_name) -- customer
        topology, not secret; site-gated for scoped callers. Credentials appear only as opaque vault/id
        references (credential_id, vault_credential_id, ad_credential_id) -- never a plaintext secret.'
      properties:
        context_id:
          type: string
        vm_name:
          type: string
        source_platform:
          type: string
          enum:
          - vmware
          - cloudstack
          - hyperv
          - nutanix
          - aws
          - azure
          - gcp
          - proxmox
          - physical
          description: Source hypervisor/platform.
        vmware_vm_id:
          type:
          - string
          - 'null'
          description: Platform VM id (VMware). Always present; null for non-VMware.
        cloudstack_vm_id:
          type:
          - string
          - 'null'
          description: Platform VM id (CloudStack). Always present; null otherwise.
        hyperv_vm_id:
          type:
          - string
          - 'null'
          description: Platform VM id (Hyper-V). Always present; null otherwise.
        nutanix_vm_id:
          type:
          - string
          - 'null'
          description: Platform VM id (Nutanix). Always present; null otherwise.
        vm_path:
          type:
          - string
          - 'null'
          description: Source inventory path (estate topology). Always present; may be null.
        guest_address:
          type: string
          description: User-maintained IP or FQDN of the running guest (restore-to-server prefill). Omitted
            when unset.
        datacenter:
          type:
          - string
          - 'null'
          description: Source datacenter. Always present; may be null.
        vcenter_host:
          type:
          - string
          - 'null'
          description: Source vCenter host/IP (internal estate topology). Always present; may be null.
        vmware_folder_path:
          type: string
          description: VMware inventory folder path. Omitted when unset.
        vmware_resource_pool_path:
          type: string
          description: VMware resource-pool path. Omitted when unset.
        vmware_resource_pool_name:
          type: string
          description: VMware resource-pool name. Omitted when unset.
        vmware_resource_pool_moref:
          type: string
          description: VMware resource-pool managed-object reference. Omitted when unset.
        credential_id:
          type:
          - integer
          - 'null'
          description: Legacy integer credential reference. Always present; may be null (vault-based VMs
            use vault_credential_id).
        vault_credential_id:
          type: string
          description: credential_vault.id reference (takes precedence over credential_id). Omitted when
            unset. A reference only -- never the secret.
        site_id:
          type:
          - string
          - 'null'
          description: Owning site for SNA routing / tenant scope. Always present; null = unclaimed (staff-only
            under enforce mode).
        cloudstack_instance_name:
          type:
          - string
          - 'null'
          description: CloudStack instance name (estate topology). Always present; may be null.
        storage_backend:
          type: string
          enum:
          - zfs
          - linstor
          - qcow2
          - storpool
          - raw
          description: Per-VM storage-backend override. Omitted when unset.
        ossea_config_id:
          type:
          - integer
          - 'null'
          description: CloudStack source config id. Always present; may be null.
        cpu_count:
          type:
          - integer
          - 'null'
          description: Always present; may be null when discovery could not determine it.
        memory_mb:
          type:
          - integer
          - 'null'
          description: Always present; may be null.
        os_type:
          type:
          - string
          - 'null'
          description: Discovered guest OS type. Always present; may be null.
        failover_os_type_id:
          type: string
          description: Per-VM CloudStack ostypeid override for DR failover. Omitted when unset (family
            default is used).
        power_state:
          type:
          - string
          - 'null'
          description: Discovered power state. Always present; may be null.
        firmware:
          type: string
          enum:
          - bios
          - efi
          description: Source firmware as discovered. Omitted when unknown (DR failover refuses rather
            than defaulting).
        secure_boot_enabled:
          type: boolean
          description: Discovered Secure Boot flag. Omitted when unknown.
        app_type:
          type: string
          enum:
          - active_directory
          - sql_server
          - exchange
          - file_server
          - other
          description: Application type for ILR/ESE recovery (user-configured). Omitted when unset.
        ad_credential_id:
          type: string
          description: credential_vault.id for the per-VM Active Directory credential. Omitted when unset.
            A reference only.
        repository_id:
          type:
          - string
          - 'null'
          description: Target backup repository id. Always present; may be null.
        pattern_id:
          type: string
          description: Protection pattern id. Omitted when unset.
        protection_enabled:
          type: boolean
          description: Schema default 1 for every row -- NOT a live protection indicator.
        last_full_backup_id:
          type: string
          description: Omitted when unset.
        force_next_full:
          type: boolean
          description: When true, the next backup is forced full.
        current_chain_id:
          type: string
          description: Active backup chain id. Omitted when unset.
        replication_status:
          type: string
          enum:
          - discovered
          - replicating
          - ready_for_failover
          - failed_over_test
          - failed_over_live
          - completed
          - failed
          - cleanup_required
          description: Replication lifecycle state.
        current_replication_job_id:
          type:
          - string
          - 'null'
          description: Always present; may be null.
        total_replications_run:
          type: integer
        successful_replications:
          type: integer
        failed_replications:
          type: integer
        first_replication_at:
          type:
          - string
          - 'null'
          format: date-time
          description: Always present; may be null.
        last_replication_at:
          type:
          - string
          - 'null'
          format: date-time
          description: Always present; may be null.
        total_backups_run:
          type: integer
        successful_backups:
          type: integer
        failed_backups:
          type: integer
        last_backup_id:
          type:
          - string
          - 'null'
          description: Always present; may be null.
        last_backup_type:
          type:
          - string
          - 'null'
          enum:
          - full
          - incremental
          - differential
          - null
          description: Always present; may be null.
        last_backup_at:
          type:
          - string
          - 'null'
          format: date-time
          description: Always present; may be null.
        discovered_at:
          type: string
          format: date-time
        last_refreshed_at:
          type: string
          format: date-time
        discovery_source:
          type: string
        last_seen_at:
          type: string
          format: date-time
          description: Source presence tracking. Omitted when unset.
        source_missing_count:
          type: integer
        source_missing_since:
          type: string
          format: date-time
          description: Omitted when unset.
        management_status:
          type: string
          enum:
          - discovered
          - managed
          - ignored
          - archived
          description: Management lifecycle state.
        managed_at:
          type: string
          format: date-time
          description: Omitted when unset.
        managed_by:
          type: string
          description: Actor that placed the VM under management. Omitted when empty (non-pointer string
            with omitempty).
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required:
      - context_id
      - vm_name
      - source_platform
      - vmware_vm_id
      - cloudstack_vm_id
      - hyperv_vm_id
      - nutanix_vm_id
      - vm_path
      - datacenter
      - vcenter_host
      - credential_id
      - site_id
      - cloudstack_instance_name
      - ossea_config_id
      - cpu_count
      - memory_mb
      - os_type
      - power_state
      - repository_id
      - protection_enabled
      - force_next_full
      - replication_status
      - current_replication_job_id
      - total_replications_run
      - successful_replications
      - failed_replications
      - first_replication_at
      - last_replication_at
      - total_backups_run
      - successful_backups
      - failed_backups
      - last_backup_id
      - last_backup_type
      - last_backup_at
      - discovered_at
      - last_refreshed_at
      - discovery_source
      - source_missing_count
      - management_status
      - created_at
      - updated_at
    VMContextGroupMembership:
      type: object
      description: A machine-group membership summary (element of `UnifiedVMContextWithGroups.groups`).
      properties:
        group_id:
          type: string
        group_name:
          type: string
        priority:
          type: integer
        enabled:
          type: boolean
      required:
      - group_id
      - group_name
      - priority
      - enabled
    VMContextListResponse:
      type: object
      properties:
        vm_contexts:
          type: array
          items:
            $ref: '#/components/schemas/UnifiedVMContextWithGroups'
          description: Always an array (empty, never null) -- built with make(). Foreign-site rows are
            filtered out in-handler under enforce mode.
        count:
          type: integer
          description: Length of the returned array.
      required:
      - vm_contexts
      - count
    VMSourceHealth:
      type: object
      description: Source-presence health, embedded (flattened) into the list and detail responses.
      properties:
        source_state:
          type: string
          description: '`active` or `deleted`.'
        source_deleted:
          type: boolean
        source_deleted_at:
          type: string
          format: date-time
          description: Omitted when the source is present.
        has_recovery_points:
          type: boolean
        backup_allowed:
          type: boolean
        backup_disabled_reason:
          type: string
          description: Omitted when backups are allowed.
      required:
      - source_state
      - source_deleted
      - has_recovery_points
      - backup_allowed
    VaultCredentialResponse:
      type: object
      properties:
        success:
          type: boolean
        data:
          $ref: '#/components/schemas/Credential'
      description: A single credential (masked by default).
      required:
      - success
      - data
    VaultDeleteResponse:
      type: object
      properties:
        success:
          type: boolean
          description: Always true.
        data:
          type: object
          properties:
            id:
              type: string
            deleted:
              type: boolean
          required:
          - id
          - deleted
      required:
      - success
      - data
    VaultError:
      type: object
      properties:
        success:
          type: boolean
          description: Always false.
        error:
          $ref: '#/components/schemas/VaultErrorDetail'
      description: 'A vault handler error. NOTE: this shape (success:false + error{code,message}) differs
        from the auth/recovery envelope (401/403/423 use {error,details,timestamp}).'
      required:
      - success
      - error
    VaultErrorDetail:
      type: object
      properties:
        code:
          type: string
        message:
          type: string
      required:
      - code
      - message
    VaultTestResponse:
      type: object
      properties:
        success:
          type: boolean
        data:
          $ref: '#/components/schemas/VaultTestResult'
      required:
      - success
      - data
    VaultTestResult:
      type: object
      properties:
        status:
          type: string
          description: '`success`, `failed`, `pending`, or `unknown`. A validation/connection failure
            is reported here with HTTP 200, not a 4xx.'
        message:
          type: string
        details:
          type: object
          additionalProperties: true
        tested_at:
          type: string
          format: date-time
        duration_ms:
          type: integer
      description: A credential test result. No credential value is echoed (details carry only connection
        metadata).
      required:
      - status
      - message
      - tested_at
      - duration_ms
    VerificationStatus:
      type: object
      properties:
        valid:
          type: boolean
        status:
          type: string
          description: e.g. `VALID`, `UNREACHABLE`, `INVALID`.
        failure_code:
          type: string
        checked_at:
          type:
          - string
          - 'null'
          format: date-time
      required:
      - valid
      - status
paths:
  /api/v1/discovery/execute:
    post:
      operationId: executeDiscovery
      summary: Trigger a discovery run
      tags:
      - Discovery
      description: 'Start an ASYNC discovery run and return an `execution_id` to poll via `GET /discovery/executions/{id}`.
        Requires `inventory.discover` (a graded self-service slice of write -- a tenant can rescan its
        OWN site). Recovery-mode 423-capable. The target is chosen by the body `site_id`: omitted -> the
        orchestrator''s default; a concrete site -> gated to the caller''s scope (foreign/unclaimed ->
        404); the literal `"global"` -> fans out across every site (incl. other tenants'') and requires
        estate-wide/staff access (-> 403 otherwise). NOTE: success is 200 (not 202) even though the run
        is asynchronous; `status` is `"running"`.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExecuteDiscoveryRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExecuteDiscoveryResponse'
        '400':
          description: Malformed JSON or a missing `triggered_by`. JSON `{status:"error",message}` (DiscoveryError).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DiscoveryError'
        '403':
          description: 'TWO shapes: (a) the caller lacks `inventory.discover` -> middleware JSON `{error,details,timestamp}`
            (ErrorResponse); (b) `site_id`==`"global"` without estate-wide/staff access -> handler JSON
            `{status:"error",message}` (DiscoveryError). Modeled as a oneOf.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DiscoveryEnvelopeError'
        '404':
          description: The body `site_id` is a foreign/unclaimed site (enforce mode) -> site-gate JSON
            `{error:"not found"}` (existence-hiding) -- ErrorResponse-compatible.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: The selected orchestrator is unavailable (e.g. the global orchestrator is not yet
            implemented, or a site orchestrator is nil). JSON `{status:"error",message}` (DiscoveryError).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DiscoveryError'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/discovery/executions/{id}:
    get:
      operationId: getDiscoveryExecution
      summary: Get a discovery execution
      tags:
      - Discovery
      description: 'Poll one discovery run by execution id (the id returned by `POST /discovery/execute`).
        Requires `inventory.read` (held by user JWT / site-API-token / gui-service, and by the sca-service
        read bearer -- which therefore also reaches the other two GET routes, unscoped). Per-object site-gated:
        a foreign-site OR unknown id is denied with a JSON existence-hiding 404 before the handler (the
        gate resolves the row''s site first, in every scope mode). A global run''s record NAMES the sites
        it spanned (`sites_included*`) -- hence the strict gate.'
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DetailedExecutionResponse'
        '404':
          description: 'TWO possible shapes: (a) the site gate -- a foreign-site OR unknown/not-yet-inserted
            id -> JSON `{error:"not found"}` (ErrorResponse) before the handler, in EVERY scope mode (the
            gate resolves the row''s site first and denies a missing row before the off/warn/enforce matrix);
            (b) the handler''s own `{status:"error",message}` 404 echoing the id (DiscoveryError) -- effectively
            UNREACHABLE while the site gate is wired (production), since (a) intercepts a missing id first.
            Modeled as a oneOf; (b) is a harmless superset kept for the gate-unwired edge.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DiscoveryEnvelopeError'
        '500':
          description: The repository failed to load the execution. JSON `{status:"error",message}` (DiscoveryError).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DiscoveryError'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/discovery/unified:
    post:
      operationId: unifiedDiscoverVMs
      summary: Discover VMs from a source (unified)
      tags:
      - Discovery
      description: Enumerate VMs from a source platform (VMware via SNA / CloudStack / Nutanix) in a platform-agnostic
        shape, optionally persisting them to `vm_contexts` (`save_to_db`). Requires `inventory.write`
        -- STAFF/operator only (a tenant cannot call this). Recovery-mode 423-capable. Credentials are
        supplied by REFERENCE (`vault_credential_id` for vmware/nutanix, `cloudstack_source_id` for cloudstack)
        and injected server-side from the vault; the response never contains a secret. Not idempotent
        when `save_to_db=true` (writes vm_contexts; UUID collisions are skipped, not errored -- see `skipped_vms`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UnifiedDiscoveryRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UnifiedDiscoveryResponse'
        '400':
          description: Malformed JSON, an unknown `platform` (must be vmware/cloudstack/nutanix), or a
            bad source config (missing `vault_credential_id` for vmware/nutanix; the deprecated `credential_id`
            was sent). JSON body `{status:"error",message}` (DiscoveryError).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DiscoveryError'
        '500':
          description: The source enumeration (`ListVMs`) failed -- raw upstream/source error text is
            echoed in `message` (may include source host detail). JSON `{status:"error",message}` (DiscoveryError).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DiscoveryError'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/assignments/{assignment_id}:
    get:
      operationId: getDRAssignment
      summary: Read a pattern assignment
      tags:
      - Pattern assignments
      description: 'One VM''s assignment to a replication pattern: its state, its target and its error.
        Tenancy comes from the pattern''s recovery site, so an out-of-scope assignment returns **404,
        not 403**. Requires `dr.replication.read`.'
      security: &id001
      - sessionBearer: []
      - providerApiToken: []
      parameters:
      - name: assignment_id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternAssignment'
              example:
                id: asg-0000
                pattern_id: pat-0000
                vm_context_id: vmctx-0001
                status: provisioning
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/assignments/{assignment_id}/cancel:
    post:
      operationId: cancelDRAssignment
      summary: Cancel an in-flight VM attachment
      tags:
      - Pattern assignments
      description: 'Request cancellation of provisioning. Partially created resources are cleaned up automatically.
        **If provisioning had already completed, the assignment stays active** — cancellation is not a
        delete; detach the VM from its pattern instead. Returns **202**. Requires `dr.replication.write`.
        RETRY: check state before retrying — a replay is NOT unconditionally safe. A second cancel answers
        409, not a no-op. Read the assignment''s status before retrying.'
      security: *id001
      parameters:
      - name: assignment_id
        in: path
        required: true
        schema:
          type: string
      x-bodyless-request: true
      responses:
        '202':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRAssignmentActionResponse'
              example:
                message: Provisioning cancellation requested; partially created resources are cleaned
                  up automatically. If provisioning had already completed, the assignment stays active.
                assignment_id: asg-0000
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/assignments/{assignment_id}/retry:
    post:
      operationId: retryDRAssignment
      summary: Retry a failed VM attachment
      tags:
      - Pattern assignments
      description: 'Re-queue provisioning for an assignment that failed. **An explicit retry route, so
        replay is safe by construction**, and it is the only way to recover a failed attach. Returns **202**.
        Requires `dr.replication.write`. RETRY: do NOT replay this call. The operation has its own retry
        route, which is the supported way to try again. This IS the retry route for a failed pattern assignment.
        Do not replay the attach that failed; call this.'
      security: *id001
      parameters:
      - name: assignment_id
        in: path
        required: true
        schema:
          type: string
      x-bodyless-request: true
      responses:
        '202':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRAssignmentActionResponse'
              example:
                message: Assignment queued for retry
                assignment_id: asg-0000
        '409':
          description: '`PATTERN_DELETE_PENDING`: the assignment''s pattern has an accepted delete in
            progress; nothing is retried — the pattern answers 404 once its cleanup completes.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/failover/jobs/{job_id}:
    get:
      operationId: getDRFailoverJob
      summary: Read a failover job
      tags:
      - Failover jobs
      description: 'One VM''s failover job: status, current step, progress and error. Its tenancy is inherited
        from the replication target, so an out-of-scope job returns **404, not 403**. Status `pending_commit`
        means the cutover completed and awaits the irreversible commit. Requires `dr.replication.read`.'
      security: &id002
      - sessionBearer: []
      - providerApiToken: []
      parameters:
      - name: job_id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRFailoverJob'
              example:
                id: fo-0000
                replication_target_id: tgt-0000
                failover_type: planned
                status: pending_commit
                progress_percent: 100
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      operationId: cancelDRFailoverJob
      summary: Cancel a failover job
      tags:
      - Failover jobs
      description: 'Stop an in-flight failover. What this can still undo depends how far the cutover got;
        the response reports what was done. Requires `dr.replication.write`. RETRY: check state before
        retrying — a replay is NOT unconditionally safe. NOT a no-op on replay. CancelFailover admits
        only pending and syncing_final and then writes status=failed, so a second call fails that guard
        and the handler answers 500 — which a caller will read as a transient fault and retry in a loop.
        The first call may also have run cleanup, destroying provisioned resources. Read the job''s status
        before retrying.'
      security: *id002
      parameters:
      - name: job_id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRFailoverCancelResponse'
              example:
                job_id: fo-0000
                cancelled: true
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/failover/jobs/{job_id}/journal:
    get:
      operationId: getDRFailoverJournal
      summary: Failover job journal
      tags:
      - Failover jobs
      description: The per-step journal for a failover job. **This is the audit trail an external orchestrator
        reads to explain a cutover** — what ran, in what order, and what compensation was applied. Requires
        `dr.replication.read`.
      security: *id002
      parameters:
      - name: job_id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRFailoverJournalResponse'
              example:
                job_id: fo-0000
                entries: []
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/failover/jobs/{job_id}/rollback/retry:
    post:
      operationId: retryDRRollbackJob
      summary: Retry a failed rollback
      tags:
      - Failover jobs
      description: '**Destructive.** Resume a rollback that FAILED part-way: it powers the source VM back
        on and destroys the promoted destination copy, exactly as the target rollback route does. Requires
        `dr.replication.write` (applied by the SHA edge to every DR route) PLUS `dr.rollback`, which is
        graded by path in the middleware — a caller holding only `dr.replication.write` is refused with
        **403**. **Eligible ONLY for a failover job whose status is `rollback_failed`.** Any other status
        is refused with **409** `ROLLBACK_RETRY_NOT_ELIGIBLE`, whose body names the job''s current `status`
        and the `eligible_status`. In particular a live failover sitting in `pending_commit` is REFUSED:
        retrying there would start a first rollback rather than resume a failed one, ending a cutover
        in progress. To roll back a failover that has not been rolled back yet, use `POST /api/v1/dr/targets/{id}/rollback`.
        A job wedged in `rolling_back` is moved to `rollback_failed` by the appliance''s wedge sweep,
        so it becomes retryable without operator surgery, and a retry whose SETUP fails leaves the job
        in `rollback_failed` rather than stranding it. **Not available on a VMware destination:** that
        path''s own rollback validation admits only `completed` and `pending_commit`, so a VMware-destination
        retry is refused by the engine with **500**. Roll a VMware-destination failover back through `POST
        /api/v1/dr/targets/{id}/rollback` before it reaches a failed state. This route re-runs an irreversible
        action; it does NOT replay a stored, previously authorised one, and nothing carries the original
        confirmation forward. It therefore requires its own typed `confirm` matching the job''s target
        `source_vm_name`, plus a non-empty `reason`, matched server-side before anything starts. A refusal
        is **400** with `code: CONFIRMATION_REQUIRED` and `confirm_with`. The audit actor is derived from
        your authenticated identity. Returns **202**; the rollback runs asynchronously, so poll `GET /api/v1/dr/failover/jobs/{job_id}`
        for its outcome. RETRY: do NOT replay the rollback trigger. This IS the retry route, and it is
        bounded to the failed state — replaying it once the rollback is running or has succeeded answers
        409 `ROLLBACK_RETRY_NOT_ELIGIBLE` naming the job''s current status, so a lost response is diagnosable
        without starting anything.'
      security: *id002
      parameters:
      - name: job_id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRIrreversibleRequest'
            example:
              confirm: workload-01
              reason: resume the rollback that failed at disk detach
      responses:
        '202':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRRollbackRetryResponse'
              example:
                message: Rollback retry started
                job_id: fo-0000
        '400':
          description: 'The typed confirmation is missing or does not match the job''s target `source_vm_name`,
            or the `reason` is empty. The body carries `code: CONFIRMATION_REQUIRED` and `confirm_with`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRConfirmationRefusal'
        '403':
          description: The caller does not hold `dr.rollback`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: The failover job is outside the caller's authorised scope, does not exist, or could
            not be resolved. All three are deliberately indistinguishable, and an unresolvable job fails
            closed rather than proceeding.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: The job is not a failed rollback (`ROLLBACK_RETRY_NOT_ELIGIBLE`, with `status`
            and `eligible_status`), or another operation holds the target's lock (`OPERATION_BLOCKED`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRRollbackRetryConflict'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/infra/disk-offerings:
    get:
      operationId: listDRDiskOfferings
      summary: List destination disk offerings
      tags:
      - DR infrastructure lookups
      description: Disk offerings in the destination cloud, for a pattern's `disk_offering_id`. Registered
        twice on the DR router — once inside the `/infra` subrouter and once as an absolute safety fallback
        — and both registrations serve this same contract. Read-only lookup against the configured source
        or destination, so results reflect that platform at call time rather than stored state. Requires
        `dr.replication.read`.
      security: &id003
      - sessionBearer: []
      - providerApiToken: []
      parameters:
      - name: site_id
        in: query
        required: false
        description: Resolve against this site's pinned credential. Required where a site holds more than
          one.
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRInfraListResponse'
              example:
                success: true
                data: []
        '502':
          description: The source or destination platform could not be reached, or rejected the lookup.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/infra/networks:
    get:
      operationId: listDRNetworks
      summary: List destination networks
      tags:
      - DR infrastructure lookups
      description: Networks in the destination cloud, for a pattern's `network_id` and a test failover's
        isolated network. Read-only lookup against the configured source or destination, so results reflect
        that platform at call time rather than stored state. Requires `dr.replication.read`.
      security: *id003
      parameters:
      - name: site_id
        in: query
        required: false
        description: Resolve against this site's pinned credential. Required where a site holds more than
          one.
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRInfraListResponse'
              example:
                success: true
                data: []
        '502':
          description: The source or destination platform could not be reached, or rejected the lookup.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/infra/offerings:
    get:
      operationId: listDRServiceOfferings
      summary: List destination service offerings
      tags:
      - DR infrastructure lookups
      description: Compute service offerings in the destination cloud. Read-only lookup against the configured
        source or destination, so results reflect that platform at call time rather than stored state.
        Requires `dr.replication.read`.
      security: *id003
      parameters:
      - name: site_id
        in: query
        required: false
        description: Resolve against this site's pinned credential. Required where a site holds more than
          one.
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRInfraListResponse'
              example:
                success: true
                data: []
        '502':
          description: The source or destination platform could not be reached, or rejected the lookup.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/infra/os-types:
    get:
      operationId: listDROSTypes
      summary: List destination OS types
      tags:
      - DR infrastructure lookups
      description: OS types in the destination cloud, for a target's `failover_os_type_id`. Read-only
        lookup against the configured source or destination, so results reflect that platform at call
        time rather than stored state. Requires `dr.replication.read`.
      security: *id003
      parameters:
      - name: site_id
        in: query
        required: false
        description: Resolve against this site's pinned credential. Required where a site holds more than
          one.
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRInfraListResponse'
              example:
                success: true
                data: []
        '502':
          description: The source or destination platform could not be reached, or rejected the lookup.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/infra/site-destination-defaults:
    get:
      operationId: getDRSiteDestinationDefaults
      summary: Site destination defaults
      tags:
      - DR infrastructure lookups
      description: The destination placement defaults resolved for a site — the starting point for a pattern's
        template, network, offering and zone. Pass `site_id`. Read-only lookup against the configured
        source or destination, so results reflect that platform at call time rather than stored state.
        Requires `dr.replication.read`.
      security: *id003
      parameters:
      - name: site_id
        in: query
        required: false
        description: Resolve against this site's pinned credential. Required where a site holds more than
          one.
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRSiteDestinationDefaults'
              example:
                site_id: site-london
                template_id: tmpl-0000
                network_id: net-0000
        '502':
          description: The source or destination platform could not be reached, or rejected the lookup.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/infra/templates:
    get:
      operationId: listDRTemplates
      summary: List destination templates
      tags:
      - DR infrastructure lookups
      description: Templates in the destination cloud, for a pattern's `template_id`. Read-only lookup
        against the configured source or destination, so results reflect that platform at call time rather
        than stored state. Requires `dr.replication.read`.
      security: *id003
      parameters:
      - name: site_id
        in: query
        required: false
        description: Resolve against this site's pinned credential. Required where a site holds more than
          one.
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRInfraListResponse'
              example:
                success: true
                data: []
        '502':
          description: The source or destination platform could not be reached, or rejected the lookup.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/infra/vmware/controller-templates:
    get:
      operationId: listDRVMwareControllerTemplates
      summary: List source controller templates
      tags:
      - DR infrastructure lookups
      description: Controller templates available on the VMware source. Read-only lookup against the configured
        source or destination, so results reflect that platform at call time rather than stored state.
        Requires `dr.replication.read`.
      security: *id003
      parameters:
      - name: site_id
        in: query
        required: false
        description: Resolve against this site's pinned credential. Required where a site holds more than
          one.
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRInfraListResponse'
              example:
                success: true
                data: []
        '502':
          description: The source or destination platform could not be reached, or rejected the lookup.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/infra/vmware/datastores:
    get:
      operationId: listDRVMwareDatastores
      summary: List source datastores
      tags:
      - DR infrastructure lookups
      description: Datastores on the VMware source. Read-only lookup against the configured source or
        destination, so results reflect that platform at call time rather than stored state. Requires
        `dr.replication.read`.
      security: *id003
      parameters:
      - name: site_id
        in: query
        required: false
        description: Resolve against this site's pinned credential. Required where a site holds more than
          one.
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRInfraListResponse'
              example:
                success: true
                data: []
        '502':
          description: The source or destination platform could not be reached, or rejected the lookup.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/infra/vmware/folders:
    get:
      operationId: listDRVMwareFolders
      summary: List source folders
      tags:
      - DR infrastructure lookups
      description: Folders on the VMware source. Read-only lookup against the configured source or destination,
        so results reflect that platform at call time rather than stored state. Requires `dr.replication.read`.
      security: *id003
      parameters:
      - name: site_id
        in: query
        required: false
        description: Resolve against this site's pinned credential. Required where a site holds more than
          one.
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRInfraListResponse'
              example:
                success: true
                data: []
        '502':
          description: The source or destination platform could not be reached, or rejected the lookup.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/infra/vmware/networks:
    get:
      operationId: listDRVMwareNetworks
      summary: List source networks
      tags:
      - DR infrastructure lookups
      description: Networks on the VMware source. Read-only lookup against the configured source or destination,
        so results reflect that platform at call time rather than stored state. Requires `dr.replication.read`.
      security: *id003
      parameters:
      - name: site_id
        in: query
        required: false
        description: Resolve against this site's pinned credential. Required where a site holds more than
          one.
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRInfraListResponse'
              example:
                success: true
                data: []
        '502':
          description: The source or destination platform could not be reached, or rejected the lookup.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/infra/vmware/resource-pools:
    get:
      operationId: listDRVMwareResourcePools
      summary: List source resource pools
      tags:
      - DR infrastructure lookups
      description: Resource pools on the VMware source. Read-only lookup against the configured source
        or destination, so results reflect that platform at call time rather than stored state. Requires
        `dr.replication.read`.
      security: *id003
      parameters:
      - name: site_id
        in: query
        required: false
        description: Resolve against this site's pinned credential. Required where a site holds more than
          one.
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRInfraListResponse'
              example:
                success: true
                data: []
        '502':
          description: The source or destination platform could not be reached, or rejected the lookup.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/infra/zones:
    get:
      operationId: listDRZones
      summary: List destination zones
      tags:
      - DR infrastructure lookups
      description: Availability zones in the destination cloud. Read-only lookup against the configured
        source or destination, so results reflect that platform at call time rather than stored state.
        Requires `dr.replication.read`.
      security: *id003
      parameters:
      - name: site_id
        in: query
        required: false
        description: Resolve against this site's pinned credential. Required where a site holds more than
          one.
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRInfraListResponse'
              example:
                success: true
                data: []
        '502':
          description: The source or destination platform could not be reached, or rejected the lookup.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/metrics/success-rate:
    get:
      operationId: getDRSuccessRateMetrics
      summary: Replication success-rate trend
      tags:
      - DR replication metrics
      description: Aggregate replication success rate over a period, bucketed for trending. Site-filtered
        under multi-tenant enforcement. Requires `dr.replication.read`.
      security:
      - sessionBearer: []
      - providerApiToken: []
      parameters:
      - name: period
        in: query
        required: false
        description: Reporting period, e.g. 7d.
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRSuccessRateResponse'
              example:
                period: 7d
                bucket: day
                buckets: []
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/orphaned-targets:
    get:
      operationId: listDROrphanedTargets
      summary: List orphaned replication targets
      tags:
      - Orphaned targets
      description: Replication targets that no pattern assignment claims. **These are what block a site
        delete**, so provider offboarding automation needs them. Site-filtered like every other list.
        Requires `dr.replication.read`.
      security: &id004
      - sessionBearer: []
      - providerApiToken: []
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DROrphanedTargetListResponse'
              example:
                orphaned_targets: []
                total: 0
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/orphaned-targets/{id}:
    get:
      operationId: getDROrphanedTarget
      summary: Read an orphaned target's removal plan
      tags:
      - Orphaned targets
      description: 'What removing this orphaned target would do, including anything that would be discarded,
        and the `plan_token` the removal must quote. Read it immediately before calling remove: the token
        is a digest of the plan (target status, controller VM, failover-history counts, lock state, confirmations,
        blockers, collateral assignments), so any change to what the removal would destroy changes the
        token and a stale one is refused. `confirmations_required[]` names each acknowledgement the removal
        needs; `removable` is false and `blockers[]` says why when this route cannot remove it at all.
        Requires `dr.replication.read`. Site-gated: another tenant''s target, like a missing one, answers
        404.'
      security: *id004
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DROrphanRemovalPlan'
              example:
                target_id: tgt-0000
                vm_context_id: ctx-0000
                source_vm_name: app-01
                site_id: site-london
                status: rolled_back
                cutover_safety_state: ordinary
                vm_context_missing: false
                controller_vm_id: vm-0000
                failover_jobs: 1
                sync_jobs: 4
                created_at: '2026-08-01T09:00:00Z'
                updated_at: '2026-08-20T09:00:00Z'
                summary: Replication target with failover history; its pattern no longer claims it.
                pure_garbage: false
                destroys:
                - 1 failover job and its state journal
                - 4 sync job records
                confirmations_required:
                - key: discard_failover_history
                  reason: 'Removing this target permanently destroys the record of 1 cutover: who ran
                    it, when, and whether it was committed or rolled back. It cannot be recovered.'
                - key: discard_provider_resources
                  reason: Controller VM vm-0000 and its replica volumes are left running in the destination
                    cloud, unmanaged. Removing the record does not delete them; confirm only if you have
                    already removed them or intend to by hand.
                recommended_action: remove
                removable: true
                plan_token: v2:9f2c...
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/orphaned-targets/{id}/remove:
    post:
      operationId: removeDROrphanedTarget
      summary: Remove an orphaned replication target
      tags:
      - Orphaned targets
      description: '**Destructive.** Remove an orphaned replication target''s records. The body is FLAT:
        `plan_token` from `getDROrphanedTarget`, plus one boolean per confirmation the plan named (today
        `discard_failover_history` and `discard_provider_resources`; the shape is open so a newer plan
        can name a key an older client does not know). A target with nothing to lose (`confirmations_required`
        empty) removes with an empty body. **What it does NOT do:** it never touches the destination cloud
        — the controller VM and its replica volumes stay exactly as they are; `discard_provider_resources`
        is your acknowledgement of that, not an instruction to delete them. That is the whole exit for
        a target whose `cutover_safety_state` is `promoted` or `reconciliation_required`: Sendense will
        never delete those destination resources, so removing the record here leaves them unmanaged and
        reconciling them is yours to do. This is record removal, not destination cleanup. Note the confirmation
        is asked whenever the target records a controller VM, and additionally for `cutover_protected`
        and `reconciliation_required` even when it does not — a blank controller pointer is what a retired
        or never-recorded controller looks like, not proof of absence. It is NOT asked for a `promoted`
        or `committed` target: that VM is the tenant’s production workload and is KEPT, so the plan carries
        a disclosure saying exactly that rather than a confirmation inviting you to discard it. An empty
        `confirmations_required` on such a target therefore does NOT mean there is nothing to lose. It
        permanently deletes the target''s failover jobs and their state journal (`discard_failover_history`)
        and its sync-job records. Requires `dr.replication.write`. **Refusals, all 409:** `confirmations_required`
        missing or false → `confirmations_required[]` names them and `target` carries the current plan;
        a missing or stale `plan_token` → `plan_changed: true` with the current plan (fetch the target
        again and confirm against what it now says); the target stopped being an orphan while being confirmed;
        or the target cannot be removed here (`blockers`). 503 when the audit log cannot record the attempt
        — the removal does not proceed unrecorded. The attempt is audited whether it is denied, refused
        or completed, with the calling credential as actor. RETRY: check state before retrying — a second
        remove answers 404, not a no-op; read the orphaned-target list first.'
      security: *id004
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DROrphanRemovalRequest'
            example:
              plan_token: v2:9f2c...
              discard_failover_history: true
              discard_provider_resources: true
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DROrphanRemovalResponse'
              example:
                message: Orphaned replication target removed
                target_id: tgt-0000
                discarded:
                  failover_jobs: 1
                  failover_state_journal: 12
                  sync_jobs: 4
                target:
                  target_id: tgt-0000
                  vm_context_id: ctx-0000
                  source_vm_name: app-01
                  site_id: site-london
                  status: rolled_back
                  cutover_safety_state: ordinary
                  vm_context_missing: false
                  controller_vm_id: vm-0000
                  failover_jobs: 1
                  sync_jobs: 4
                  created_at: '2026-08-01T09:00:00Z'
                  updated_at: '2026-08-20T09:00:00Z'
                  summary: Replication target with failover history; its pattern no longer claims it.
                  pure_garbage: false
                  destroys:
                  - 1 failover job and its state journal
                  - 4 sync job records
                  confirmations_required:
                  - key: discard_failover_history
                    reason: Removing this target permanently destroys the record of 1 cutover. It cannot
                      be recovered.
                  - key: discard_provider_resources
                    reason: Controller VM vm-0000 and its replica volumes are left in the destination
                      cloud, unmanaged.
                  recommended_action: remove
                  removable: true
                  plan_token: v2:9f2c...
        '409':
          description: 'Refused: `confirmations_required` (each missing acknowledgement named; `target`
            is the current plan), `plan_changed: true` (the plan token is missing or no longer matches
            — re-read the plan), the target stopped being an orphan, or it cannot be removed here (`target.blockers`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DROrphanRemovalConflict'
        '503':
          description: The audit log could not record the attempt; nothing was removed. Restore the audit
            log and retry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '400':
          description: The body is not valid JSON.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/patterns:
    get:
      operationId: listDRPatterns
      summary: List replication patterns
      tags:
      - Replication patterns
      description: 'Replication patterns visible to the caller. Site-filtered under multi-tenant enforcement:
        a caller sees only patterns whose `site_id` is one of theirs. Requires `dr.replication.read`.'
      security: &id005
      - sessionBearer: []
      - providerApiToken: []
      parameters:
      - name: enabled
        in: query
        required: false
        description: Filter to enabled or disabled patterns.
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternListResponse'
              example:
                patterns: []
                total: 0
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      operationId: createDRPattern
      summary: Create a replication pattern
      tags:
      - Replication patterns
      description: 'Create a replication pattern. Omitting `vm_context_ids` and `vm_assignments` creates
        a **zero-VM dormant pattern**, which is a supported stable state for staging a tenant''s DR shape
        before any workload exists. `site_id` must name an existing site. Pattern names are unique per
        site. **Not safe to replay.** This operation takes no `Idempotency-Key` — only the two irreversible
        commits and tenant creation do — and the operation lock is mutual exclusion, not identity, so
        a replay after the lock releases runs again. A second call with a different name creates a second
        pattern; a same-name replay in the same site returns 409. Requires `dr.replication.write`. RETRY:
        check state before retrying — a replay is NOT unconditionally safe. A replay of the same body
        cannot create a duplicate pattern: names are unique per site and, unlike VM attachment, that uniqueness
        is backed by real DDL (uk_dr_patterns_site_name, migration 20260808100000) rather than by an application
        check alone, so the second call is refused with 409 Pattern conflict. It does not SUCCEED though,
        and the create also attaches VMs and starts provisioning, so read the pattern list for the name
        before retrying. VMs named at create time are attached by the same path as `addDRPatternVMs` and
        judged by the same rules, including the same-tenant rule: a VM whose source site belongs to another
        tenant than `site_id` is refused 409 `CROSS_TENANT_ATTACHMENT` and no pattern is created.'
      security: *id005
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRPatternCreateRequest'
            example:
              name: gold-tier
              site_id: site-london
              sync_interval_minutes: 60
              template_id: tmpl-0000
              network_id: net-0000
              disk_offering_id: do-0000
              destination_vault_credential_id: cred-0000
              enabled: false
              service_offering_id: so-fixed-2x4
              default_cpu_number: 2
              default_memory_mb: 4096
      responses:
        '201':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternCreateResponse'
              example:
                pattern:
                  pattern_id: pat-0000
                  name: gold-tier
                  site_id: site-london
                  enabled: false
                  total_vms: 0
                  active_vms: 0
        '409':
          description: 'A pattern with that name already exists in the site, or `CROSS_TENANT_ATTACHMENT`:
            a named VM''s source site belongs to another tenant than `site_id` (nothing is created).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: 'The pattern service is unavailable, or the service offering could not be verified
            against the destination and the pattern carries no compute values (`SERVICE_OFFERING_UNVERIFIED`:
            retry when it is reachable, or send both values). Nothing was created.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRServiceOfferingRefusal'
        '400':
          description: Invalid request — including `CUSTOM_OFFERING_REQUIRES_COMPUTE` (a custom service
            offering without `default_cpu_number` and `default_memory_mb`; `missing[]` names them), `SERVICE_OFFERING_STATIC`
            (a non-customizable offering, which could never provision) and `SERVICE_OFFERING_NOT_FOUND`
            (the destination has no such offering).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRServiceOfferingRefusal'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/patterns/{pattern_id}:
    get:
      operationId: getDRPattern
      summary: Read a replication pattern
      tags:
      - Replication patterns
      description: One replication pattern with its VM assignments and a per-status breakdown. Requires
        `dr.replication.read`. An out-of-scope pattern returns **404, not 403**, so the API does not confirm
        another tenant's pattern exists.
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternDetailResponse'
              example:
                pattern:
                  pattern_id: pat-0000
                  name: gold-tier
                  site_id: site-london
                assignments: []
                by_status:
                  provisioned: 0
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    patch:
      operationId: updateDRPattern
      summary: Update a replication pattern
      tags:
      - Replication patterns
      description: 'Update the declared fields of a replication pattern. Naturally idempotent: replaying
        the same body leaves the same state. Moving a pattern to another site is validated against the
        (site, name) tuple the row will land in. Requires `dr.replication.write`. RETRY: naturally idempotent
        — a second identical call converges on the same state and answers the same way. Applies the explicit
        values in the body. A replay rewrites the same values.'
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRPatternUpdateRequest'
            example:
              sync_interval_minutes: 30
              enabled: true
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPattern'
              example:
                pattern_id: pat-0000
                name: gold-tier
                sync_interval_minutes: 30
                enabled: true
        '409':
          description: 'The new name already exists in the target site, or `PATTERN_DELETE_PENDING`: the
            pattern''s delete has been accepted and it will answer 404 once cleanup completes; nothing
            was written.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '400':
          description: Invalid update — including `CUSTOM_OFFERING_REQUIRES_COMPUTE`, `SERVICE_OFFERING_STATIC`
            and `SERVICE_OFFERING_NOT_FOUND` (see `createDRPattern`); nothing was written.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRServiceOfferingRefusal'
        '503':
          description: '`SERVICE_OFFERING_UNVERIFIED`: the offering could not be verified against the
            destination; nothing was written.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRServiceOfferingRefusal'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      operationId: deleteDRPattern
      summary: Delete a replication pattern
      tags:
      - Replication patterns
      description: 'Delete a replication pattern. **Destructive, and one complete deletion.** A pattern
        that owns no replication targets is deleted at once (**200**, `controller_vms_action: not_applicable`)
        — unless a membership is still being provisioned (its controller is being built, no target row
        yet): then the delete answers **202** `destroying` with a message saying the run is being stopped,
        and the pattern is deleted automatically once it has unwound. A pattern that still owns targets
        answers **202** `destroying`: the delete is recorded on the pattern (`delete_requested_at`), the
        pattern is disabled, one controller-VM destroy is queued per target, and the pattern is then **deleted
        automatically** as soon as the last target it owns is gone — poll `getDRPattern` until it answers
        **404**; no second delete is needed or expected. Deleting a pattern destroys the controller VMs
        of the targets it owns that are in a replication state: `destroy_vms=true` is accepted and does
        not change the outcome (there is no keep-controllers option; a deleted pattern never leaves a
        controller running unmanaged in a tenant''s cloud). **It never destroys a target whose `cutover_safety_state`
        is anything but `ordinary`**, and that state — not `status` — is what decides. There are THREE
        outcomes, and they are not the same thing. `promoted` (a commit completed; the VM is production)
        is RELEASED: the membership is removed, the record is left to the orphaned-target inventory, the
        promoted VM is untouched. `reconciliation_required` (Sendense cannot prove the destination resources
        are absent) is ALSO released, and this one is not cleanup: its destination VM and replica volumes
        may still exist and no Sendense path will ever delete them — reconcile them yourself. Both are
        listed under `released_targets`, each carrying its own `remedy` saying which it is; when nothing
        else is owed the pattern is deleted on this request (200). `cutover_protected` (a cutover is live
        or finishable) is named under `blocked_targets` with its `status` and a `remedy` and no `retry_endpoint`.
        A target whose state reads ordinary but whose `status` is a cutover state is handled by the same
        independent veto: `committed` is released, and every other cutover status is blocked (202 `partially_destroying`
        when others were queued, 409 `blocked` when none), the delete stays recorded, and the pattern
        completes once they are gone — roll a rehearsal or an uncommitted failover back (or commit it)
        and delete again. A target whose row cannot be read is likewise named as blocked (`status: unknown`)
        with nothing done to it — an unreadable row is never offered to it. Requires `dr.replication.write`.
        **Refusals:** 409 `stranded_targets` when a target the pattern claims but does not name carries
        failover history — resolve it through the orphaned-target inventory (`getDROrphanedTarget` → `removeDROrphanedTarget`
        with its plan token and confirmations), then delete again; 202 `partially_destroying` or 409 `blocked`
        when one or more targets could not be queued — a `delete_failed` target is named with its `retry_endpoint`,
        a target a failover touched with its `remedy`. **`retryDRTargetDestroy` is an appliance-operator
        route and is NOT published in the Multi-Tenant SHA provider profile.** A `delete_failed` target
        needs an explicit retry — the autonomous sweep offers it, but the destroy path declines it and
        returns `delete_failed` again. **A provider has no published route for this shape while the pattern
        still names the target:** the confirmed orphaned-target removal refuses it (`pattern … still names
        this target`), and a VM detach refuses a `delete_failed` target too, so the membership cannot
        be unbound first. It needs an appliance operator — either the destroy retry, or the abandon route,
        which discards the record and leaves the destination resources behind. Both are outside the provider
        profile — and the pattern completes once they are gone. While the delete is pending, attaching
        a VM or changing the pattern answers 409 `PATTERN_DELETE_PENDING`. A 202 with `deferred: true`
        means the delete is recorded but one or more destroys could not be queued on this request (`details`
        says why); the appliance re-offers them on its own sweep and the pattern still completes. The
        sweep additionally never destroys a target locked by a running operation (sync, failover, rollback,
        commit, cleanup, controller update); it is offered again once the lock is released (a failover
        or commit releases into a cutover state, handled as above). The request path offers targets in
        turn and stops at the first it cannot queue (202 `deferred`), so a replay may be needed once a
        lock clears; the sweep does not stop. RETRY: a replay while cleanup runs is accepted again (202)
        and keeps the original `delete_requested_at`; a replay after completion answers **404**, not 500.
        Read the pattern before retrying only to learn whether it is already gone.'
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      - name: destroy_vms
        in: query
        required: false
        description: 'Accepted for compatibility: `true` states the caller''s consent to destroy the destination
          controller VMs, which a pattern delete does regardless. Send it explicitly so the intent is
          recorded.'
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternDeleteResponse'
              example:
                message: Pattern deleted
                pattern_id: pat-0000
                controller_vms_action: not_applicable
        '202':
          description: 'Accepted: controller cleanup queued and the pattern is disabled and deleted automatically
            when cleanup completes (`destroying`); or some targets could not be queued and are named under
            `blocked_targets` — a `delete_failed` one with its retry endpoint, a target a failover touched
            with its `remedy` (`partially_destroying`); or only committed targets were released and something
            else keeps the pattern for the sweep (`destroying` with `details` saying what). `released_targets`
            lists committed targets released on this request.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternDeleteResponse'
        '404':
          description: No such pattern — including a replay after the automatic deletion completed, and
            another tenant's pattern.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: 'Refused: `stranded_targets` (a claimed target with failover history; use the orphaned-target
            routes), or `blocked` (no cleanup could be queued; each remaining target is named under `blocked_targets`
            with what unblocks it — for a `delete_failed` one an explicit destroy retry, which is an appliance-operator
            route not published in the provider profile and which a provider therefore cannot run itself;
            the `remedy` for a target a failover touched or one whose row could not be read). `released_targets`
            may accompany `blocked` (a `stranded_targets` refusal happens before any release).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternDeleteRefusal'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/patterns/{pattern_id}/commit:
    post:
      operationId: commitDRPatternFailover
      summary: Commit a pattern-wide failover
      tags:
      - Replication patterns
      description: '**Irreversible.** Committing a pattern promotes **every VM in it** and discards the
        rollback path for all of them at once. Requires `dr.replication.write` (applied by the SHA edge
        to every DR route) PLUS `dr.failover.commit`, which is separately grantable from live failover.
        Under multi-tenant enforcement this requires a typed `confirm` matching the pattern''s own name
        — resolved server-side from the id, so echoing the request cannot satisfy it — plus a non-empty
        `reason`. **Do not replay it blindly.** The operation lock is mutual exclusion, not identity,
        so it only rejects a CONCURRENT second request. After a timeout, send the same `Idempotency-Key`
        again — or, without one, read each target''s `commit/readiness` and status to establish what actually
        happened before acting. RETRY: replay-safe only with an `Idempotency-Key`. Irreversible for every
        VM in the pattern at once. Same contract as the single-target commit: Idempotency-Key makes the
        retry return the original per-VM outcome, and without one the per-VM commit conflicts appear in
        the failed list rather than as duplicate promotions. IDEMPOTENCY-KEY. Send an `Idempotency-Key`
        request header and a retry of the SAME request returns a BOUNDED RECORD of the original outcome
        with `replayed: true`, instead of doing anything. The key is scoped to this operation, this exact
        resource and your own identity, so it can never replay across tenants, sites, targets or patterns;
        it authorises nothing, and it is consulted only AFTER authentication, the permission check, the
        site gate and the typed confirmation, so it cannot bypass any of them — a replay must re-satisfy
        all four. Reusing a live key with materially different content is refused with 409 `IDEMPOTENCY_KEY_REUSED`;
        a duplicate arriving while the first call is still running is refused with 409 `IDEMPOTENCY_KEY_IN_FLIGHT`.
        If this appliance has no key store configured, a request carrying the header is refused with 503
        rather than run unprotected — you would otherwise believe an irreversible operation was replay-safe
        when it was not. THE PATTERN REPLAY IS A DIFFERENT SHAPE, because its success response is: it
        carries `pattern_id`, `job_id`, `committed`, `failed`, `fully_committed`, plus `replayed: true`
        and the `idempotency_key` you sent, and `warning` when the pattern is not fully committed. `failed`
        is a list of the same length as the original''s, so a client that counts it keeps working, but
        each entry carries EXACTLY `target_id`, plus `vm_name` when the name is known, and nothing else
        — `vm_name` is omitted when empty, matching the original response, and the free-form per-VM error
        text is NOT stored. Read `fully_committed` before treating a replayed 200 as a finished cutover:
        a PARTIAL pattern commit is terminal, so retrying it is refused rather than finishing the job,
        and the VMs under `committed` are already irreversibly promoted.'
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      - name: Idempotency-Key
        in: header
        required: false
        description: 'Makes a retried commit exactly-once. A commit that times out at the proxy is indistinguishable
          from one that never ran; with this header a retry of the SAME request returns a bounded record
          of the original outcome with `replayed: true` instead of acting again. Reusing a live key with
          materially different content is refused with 409 `IDEMPOTENCY_KEY_REUSED`; a duplicate arriving
          while the first call is still running is refused with 409 `IDEMPOTENCY_KEY_IN_FLIGHT`. If the
          appliance has no key store configured, a request carrying this header is refused with 503 rather
          than run unprotected. Persist the key BEFORE sending the request — a key you cannot recover
          after a crash protects nothing.'
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRIrreversibleRequest'
            example:
              confirm: gold-tier
              reason: cutover approved by change CR-0000
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternCommitResponse'
              example:
                pattern_id: drp-0000
                job_id: pfj-0000
                committed:
                - tgt-0000
                - tgt-0001
                failed: []
        '400':
          description: 'Two causes. Either the typed confirmation is missing or does not match the pattern''s
            `name`, or the `reason` is empty — that body carries `code: CONFIRMATION_REQUIRED` and `confirm_with`;
            or the orchestrator refused — no pattern failover awaiting commit, the pattern job is not
            in a state that can be committed, or another orchestrator error — which answers `{error, status:
            400}` with neither `code` nor `confirm_with`. Read `code` defensively.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternIrreversibleRefusal'
        '403':
          description: The caller does not hold `dr.failover.commit`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/patterns/{pattern_id}/failover:
    post:
      operationId: triggerDRPatternFailover
      summary: Fail over every VM in a pattern
      tags:
      - Replication patterns
      description: 'Cut over the whole pattern. **The permission is graded by `failover_type`, in the
        handler rather than the middleware, because the type is in the body**: `test` requires `dr.failover.test`;
        `planned` and `unplanned` require `dr.failover.live` and power off the production source VMs.
        Under multi-tenant enforcement this requires a typed `confirm` matching the pattern''s own name
        — resolved server-side from the id, so echoing the request cannot satisfy it — plus a non-empty
        `reason`. Use `excluded_vms` for a selective pattern operation. Returns **202** with the job.
        **Not safe to replay.** This operation takes no `Idempotency-Key` — only the two irreversible
        commits and tenant creation do — and the operation lock is mutual exclusion, not identity, so
        a replay after the lock releases runs again. Never replay a live cutover blindly — read the active
        job first. RETRY: a second call does not start a second operation; it reports the one already
        running and names it. The orchestrator checks for an active pattern failover job first and refuses
        with the existing job''s id and status rather than starting a second one, so a replay cannot double-cut-over
        a pattern. Resolve the named job through GET /api/v1/dr/patterns/{pattern_id}/failover/active.'
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRPatternFailoverRequest'
            example:
              failover_type: test
              parallel_limit: 2
      responses:
        '202':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternFailoverTriggerResponse'
              example:
                job:
                  id: pfo-0000
                  pattern_id: pat-0000
                  failover_type: test
                  status: pending
                  total_vms: 2
                message: Pattern failover initiated for 2 VMs
        '400':
          description: The failover type is invalid, or the typed confirmation or reason is missing or
            does not match the pattern name.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller holds neither `dr.failover.test` nor `dr.failover.live` as required
            by the requested type.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: A failover is already in flight for this pattern.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: The pattern failover orchestrator is not initialised.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/patterns/{pattern_id}/failover/active:
    get:
      operationId: getDRPatternActiveFailover
      summary: The pattern's in-flight failover
      tags:
      - Replication patterns
      description: Whether a pattern failover is currently running, and the job if so. `active` is false
        with a null `job` when none is. Requires `dr.replication.read`.
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternActiveFailoverResponse'
              example:
                active: false
                job: null
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/patterns/{pattern_id}/failover/activity:
    get:
      operationId: listDRPatternFailoverActivity
      summary: Pattern failover activity
      tags:
      - Replication patterns
      description: Failover activity history for the pattern's targets. Requires `dr.replication.read`.
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternFailoverActivityResponse'
              example:
                pattern_id: pat-0000
                activity: []
        '503':
          description: Failover activity is unavailable on this appliance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/patterns/{pattern_id}/failover/jobs/{job_id}:
    get:
      operationId: getDRPatternFailoverJob
      summary: Read a pattern failover job
      tags:
      - Replication patterns
      description: One pattern failover job with per-VM progress and a summary. **This is the provider-facing
        read for failover progress** — poll it rather than the event stream. Requires `dr.replication.read`.
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      - name: job_id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternFailoverJobResponse'
              example:
                job:
                  id: pfo-0000
                  pattern_id: pat-0000
                  status: running
                  total_vms: 2
                vm_jobs: []
                summary: {}
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/patterns/{pattern_id}/failover/jobs/{job_id}/cancel:
    post:
      operationId: cancelDRPatternFailover
      summary: Cancel an in-flight pattern failover
      tags:
      - Replication patterns
      description: 'Stop a running pattern failover. VMs already cutting over complete; pending VMs are
        skipped. Requires `dr.replication.write`. RETRY: check state before retrying — a replay is NOT
        unconditionally safe. The repository refuses when the UPDATE changed no rows, and the connection
        does not set clientFoundRows, so MySQL counts CHANGED rows against second-precision timestamps:
        a fast retry inside the same second gets 404 on an emergency stop. Read the job''s status first.'
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      - name: job_id
        in: path
        required: true
        schema:
          type: string
      x-bodyless-request: true
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternFailoverCancelResponse'
              example:
                cancelled: true
                message: Pattern failover cancelled. Running VMs will complete, pending VMs will be skipped.
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/patterns/{pattern_id}/failover/jobs/{job_id}/retry:
    post:
      operationId: retryDRPatternFailover
      summary: Retry the failed VMs of a pattern failover
      tags:
      - Replication patterns
      description: 'Retry the VMs that failed in a pattern failover. **An explicit retry route, so replay
        is safe by construction** — it acts only on VMs currently in a failed state. Omit `target_ids`
        to retry them all. Returns **202**. Requires `dr.replication.write`. RETRY: do NOT replay this
        call. The operation has its own retry route, which is the supported way to try again. This IS
        the retry route for the failed VMs of a pattern failover. Replaying the pattern failover trigger
        instead is refused while a job is active.'
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      - name: job_id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRPatternFailoverRetryRequest'
            example:
              target_ids:
              - tgt-0001
      responses:
        '202':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternFailoverRetryResponse'
              example:
                retry:
                  retried:
                  - tgt-0001
                message: Pattern failover retry started for 1 VMs
        '409':
          description: A failover is already running for this pattern.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/patterns/{pattern_id}/metrics:
    get:
      operationId: getDRPatternMetrics
      summary: Pattern replication metrics
      tags:
      - Replication patterns
      description: Aggregate replication metrics for the pattern over a period. Requires `dr.replication.read`.
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      - name: period
        in: query
        required: false
        description: Reporting period, e.g. 7d.
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternMetricsResponse'
              example:
                success: true
                pattern_id: pat-0000
                pattern_name: gold-tier
                period: 7d
                total_syncs: 0
                successful_syncs: 0
                failed_syncs: 0
                success_rate: 0
                total_bytes_transferred: 0
                average_speed_mbs: 0
                syncs_by_hour: []
                success_rate_trend: []
                speed_trend: []
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/patterns/{pattern_id}/rollback:
    post:
      operationId: rollbackDRPatternFailover
      summary: Roll back a pattern-wide failover
      tags:
      - Replication patterns
      description: '**Destructive, and in bulk.** Rollback powers the source VMs back on and **destroys
        the promoted destination VMs** for every VM in the pattern at once. Requires `dr.replication.write`
        (applied by the SHA edge to every DR route) PLUS `dr.rollback`, which is graded by path in the
        middleware and separately grantable from failover — a caller holding only `dr.replication.write`
        is refused with **403**. Under multi-tenant enforcement this requires a typed `confirm` plus a
        non-empty `reason`. The match is SERVER-SIDE against the resource''s own authoritative name, so
        echoing the request back cannot satisfy it, and the check runs BEFORE any rollback mutation begins.
        A refusal is **400** with `code: CONFIRMATION_REQUIRED` and `confirm_with` naming exactly what
        to type. If the name cannot be resolved the guard does NOT soften to reason-only: the identifier
        stands in as the expected value. The audit actor is derived from your authenticated identity and
        can never be supplied in the request body. The confirmation value is the pattern''s own `name`.
        `triggered_by` is still accepted for compatibility but no longer sets the audit actor. **Not safe
        to replay.** This operation takes no `Idempotency-Key` — only the two irreversible commits and
        tenant creation do — and the operation lock is mutual exclusion, not identity, so a replay after
        the lock releases runs again. RETRY: check state before retrying — a replay is NOT unconditionally
        safe. Rolls back every test failover in the pattern. Refused while a pattern job is active; after
        it completes a replay starts another. Read the pattern''s active job first.'
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRIrreversibleRequest'
            example:
              confirm: gold-tier
              reason: cutover rehearsal complete
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternRollbackResponse'
              example:
                pattern_id: pat-0000
                job_id: pfj-0000
                rolled_back:
                - tgt-0000
                - tgt-0001
                failed: []
        '400':
          description: 'Two causes. Either the typed confirmation is missing or does not match the pattern''s
            `name`, or the `reason` is empty — that body carries `code: CONFIRMATION_REQUIRED` and `confirm_with`;
            or the orchestrator refused — no active pattern failover to roll back, the pattern job is
            not in a state that can be rolled back, or another orchestrator error — which answers `{error,
            status: 400}` with neither `code` nor `confirm_with`. Read `code` defensively.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternIrreversibleRefusal'
        '403':
          description: The caller does not hold `dr.rollback`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: The pattern is outside the caller's authorised scope, or does not exist. The two
            are deliberately indistinguishable.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/patterns/{pattern_id}/rpo-status:
    get:
      operationId: getDRPatternRPOStatus
      summary: Pattern RPO status
      tags:
      - Replication patterns
      description: 'RPO compliance across the pattern''s replication targets. `targets` serialises as
        an empty array rather than null, so a correctly pre-created zero-VM pattern does not read as broken.
        **Read `assignment_count` alongside `summary.total`**: `summary` counts materialised targets only,
        so zero there is ambiguous between ''no VMs assigned'' and ''assigned but still provisioning''.
        Requires `dr.replication.read`.'
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternRPOStatusResponse'
              example:
                success: true
                pattern_id: pat-0000
                rpo_minutes: 60
                targets: []
                assignment_count: 0
                compliance_rate: 0
                average_rpo_minutes: 0
                summary:
                  compliant: 0
                  warning: 0
                  breached: 0
                  total: 0
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/patterns/{pattern_id}/sync:
    post:
      operationId: triggerDRPatternSync
      summary: Trigger sync for every VM in a pattern
      tags:
      - Replication patterns
      description: 'Queue a sync for every VM in the pattern. Returns **202** immediately; dispatch runs
        server-side and serially through the admission gate, so a concurrency limit queues rather than
        fails. **Not safe to replay.** The operation lock is mutual exclusion, not identity, so a replay
        after the lock releases runs again and each call opens a new dispatch; this route takes no `Idempotency-Key`
        (only the two irreversible commits and tenant creation do). `trigger_job_id` names a job-tracking
        record you CAN now resolve: poll `GET /api/v1/dr/patterns/{pattern_id}/sync-jobs/{job_id}` with
        it. That read is scoped to your own pattern''s dispatch job — the estate-wide operation listings
        remain fleet-admin gated and are unchanged. Requires `dr.replication.write`. RETRY: check state
        before retrying — a replay is NOT unconditionally safe. A replay opens a SECOND dispatch job and
        re-admits the pattern''s VMs. Resolve the trigger_job_id from the 202 through GET /api/v1/dr/patterns/{pattern_id}/sync-jobs/{job_id}
        and retry only once it is terminal.'
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRPatternSyncRequest'
            example:
              sync_type: incremental
      responses:
        '202':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternSyncResponse'
              example:
                trigger_job_id: job-0000
                pattern_id: pat-0000
                message: pattern sync dispatch accepted; watch trigger_job_id or queue_event log entries
                  for progress
        '400':
          description: '`sync_type` must be `full` or `incremental`, or `trigger_type` must be `manual`
            or `scheduled`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: The pattern sync dispatcher is not configured on this appliance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/patterns/{pattern_id}/sync-history:
    get:
      operationId: getDRPatternSyncHistory
      summary: Pattern sync history
      tags:
      - Replication patterns
      description: Sync history across the pattern's VMs. Requires `dr.replication.read`.
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternSyncHistoryResponse'
              example:
                success: true
                pattern_id: pat-0000
                syncs: []
                total: 0
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/patterns/{pattern_id}/sync-jobs/{job_id}:
    get:
      operationId: getDRPatternSyncJob
      summary: Read this pattern's own sync-dispatch job
      tags:
      - Replication patterns
      description: 'Resolve the `trigger_job_id` that `POST /api/v1/dr/patterns/{pattern_id}/sync` hands
        back, so the identifier in that 202 is actually usable. Before this route existed the only readers
        of the job-tracking table were the estate-wide `/api/v1/dr/operations` listings, which are fleet-admin
        gated by design because they carry no per-row site anchor — so a provider or tenant received an
        id nothing they could reach would resolve. This read does NOT widen those listings: it resolves
        ONE job class, bound to the pattern in the path. The job''s own recorded pattern must match, and
        every failure to establish that — a job of another pattern, a job of another type, an unresolvable
        record — answers **404, not 403**, exactly like the site gate itself, so a denial does not confirm
        existence. Requires `dr.replication.read`.'
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      - name: job_id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternSyncJob'
              example:
                trigger_job_id: job-0000
                pattern_id: pat-0000
                status: running
                percent_complete: 42
                sync_type: incremental
                trigger_type: manual
                created_at: '2026-01-01T00:00:00Z'
                terminal: false
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/patterns/{pattern_id}/vms:
    get:
      operationId: listDRPatternVMs
      summary: List a pattern's VM assignments
      tags:
      - Replication patterns
      description: The pattern's VM assignments, with a per-status breakdown. This is the provider-facing
        read for assignment state. Requires `dr.replication.read`.
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternAssignmentListResponse'
              example:
                assignments: []
                total: 0
                by_status: {}
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      operationId: addDRPatternVMs
      summary: Attach VMs to a pattern
      tags:
      - Replication patterns
      description: 'Attach source VMs to a replication pattern. **This is where source-site authorisation
        and protected-VM licence admission run** — not at pattern creation — so a licence shortfall surfaces
        here. Returns **202**: provisioning of each VM''s replication target proceeds asynchronously;
        poll the assignments until each leaves its provisioning state. **Not safe to replay.** This operation
        takes no `Idempotency-Key` — only the two irreversible commits and tenant creation do — and the
        operation lock is mutual exclusion, not identity, so a replay after the lock releases runs again.
        Requires `dr.replication.write`. RETRY: check state before retrying — a replay is NOT unconditionally
        safe. VM attachment is NOT naturally idempotent, and the model says otherwise: DRPatternAssignment.UniqueIndexes()
        advertises uk_pattern_vm, but the shipped DDL carries only non-unique keys and four places in
        this module already record that. The duplicate check is application-level and therefore racy under
        concurrency. Read the pattern''s VM list before retrying. **Attachment never bridges tenants.**
        Under enforced multi-tenancy the VM context''s source site and the pattern''s site must belong
        to the same tenant: the same site, or different sites of one tenant, are valid; a VM whose site
        belongs to another tenant is refused 409 `CROSS_TENANT_ATTACHMENT` for every caller — an estate-wide
        provider credential included — and nothing is attached. An ungrouped site on either side is not
        a tenant binding. Ownership is read from the sites'' tenant bindings, never inferred from names;
        when it cannot be established the attachment fails closed (409 `TENANCY_UNRESOLVED` for a site
        row that no longer exists, 503 `TENANCY_UNVERIFIED` for a lookup failure). A caller who cannot
        see the VM or the pattern still receives the 404 it receives today.'
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRPatternAddVMsRequest'
            example:
              vm_context_ids:
              - vmctx-0001
              - vmctx-0002
      responses:
        '202':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternAddVMsResponse'
              example:
                assignments: []
                message: Provisioning started for 2 VMs
        '400':
          description: Neither `vms` nor `vm_context_ids` was supplied, or the assignment request is otherwise
            invalid. A licence-admission refusal is NOT a 400 - see 403 and 409.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: 'Either the caller lacks the required permission, or a licence guard refused the
            attach. Both render as 403 and the status alone does not separate them: the protected-VM admission
            check answers 403 when licensing is unavailable or invalid for the workload, and the entitlement
            guard that runs before it answers 403 for an expired licence, a lapsed check-in, an insufficient
            edition or a workload released by reconciliation. Only the entitlement guard''s body carries
            a machine-readable `error` code; the admission check''s does not. An out-of-scope SOURCE site
            is 404, not 403.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: 'The protected-VM licence pool is at its limit, so the attach is refused. This
            is the shortfall a provider pre-creating dormant patterns meets first, because admission runs
            at ATTACH and not at pattern creation. On this route the entitlement guard answers 403 rather
            than 409, so a 409 here always means the pool. The body is the ordinary error shape, carrying
            a message and NO machine-readable code. Also `PATTERN_DELETE_PENDING`: the pattern''s delete
            has been accepted and its cleanup is running — nothing is attached. `CROSS_TENANT_ATTACHMENT`:
            the VM''s source site and the pattern''s site belong to different tenants (`refused[]` names
            each VM with both sites and both tenants). `TENANCY_UNRESOLVED`: the pattern''s site row no
            longer exists.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: '`TENANCY_UNVERIFIED`: the tenant ownership of the two sites could not be read;
            nothing was attached — retry.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/patterns/{pattern_id}/vms/bulk-delete:
    post:
      operationId: bulkRemoveDRPatternVMs
      summary: Remove several VMs from a pattern
      tags:
      - Replication patterns
      description: 'Detach several VMs in one call. **Destructive.** Partial success is normal: the response
        reports `removed` and `failed` separately, and names `orphan_inventory` when any removal stranded
        a target. Requires `dr.replication.write`. RETRY: naturally idempotent — a second identical call
        converges on the same state and answers the same way. Detaching VMs already detached converges;
        the set of attached VMs after the call is the same either way. Each `failed[]` entry carries the
        same detail as the single route: `stranded_targets`, `blocked_targets` with `retry_endpoint` or
        `remedy`, and `released_targets` for targets of that VM released rather than destroyed on this
        request — a `promoted` one whose VM is production, or a `reconciliation_required` one whose destination
        resources Sendense cannot account for and will never delete (the membership is gone in both cases
        even though the VM is listed under `failed`).'
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRPatternBulkRemoveRequest'
            example:
              vm_context_ids:
              - vmctx-0001
              - vmctx-0002
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternBulkRemoveResponse'
              example:
                message: Bulk VM removal completed
                pattern_id: pat-0000
                removed:
                - vmctx-0001
                - vmctx-0002
                failed: []
                total: 2
                success: 2
                controller_vm_status: cleanup_queued_or_not_required
        '400':
          description: '`vm_context_ids` is required.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/patterns/{pattern_id}/vms/{vm_context_id}:
    patch:
      operationId: updateDRPatternAssignment
      summary: Update one VM assignment
      tags:
      - Replication patterns
      description: 'Update one VM''s assignment — for example its destination network. `network_id` must
        be supplied explicitly as a string or null. Naturally idempotent. Requires `dr.replication.write`.
        RETRY: naturally idempotent — a second identical call converges on the same state and answers
        the same way. Applies the explicit values in the body. A replay rewrites the same values.'
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      - name: vm_context_id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRPatternAssignmentUpdateRequest'
            example:
              network_id: net-0001
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternAssignment'
              example:
                id: asg-0000
                pattern_id: pat-0000
                vm_context_id: vmctx-0001
                network_id: net-0001
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      operationId: removeDRPatternVM
      summary: Remove one VM from a pattern
      tags:
      - Replication patterns
      description: 'Detach one VM from the pattern. **Destructive**: its replication target and destination
        controller VM are cleaned up — unless the target''s `cutover_safety_state` is anything but `ordinary`,
        which is what decides here rather than `status`. A `promoted` or `reconciliation_required` target
        is RELEASED instead: the membership is removed, the record and the destination are left alone,
        and the response is **200** with `controller_vm_status: released_not_destroyed` and `released_targets`
        — each entry''s `remedy` says whether this is a completed migration whose VM is production, or
        a target whose destination resources Sendense cannot account for and will never delete. (On a
        409, when another target of the same VM is refused, the releases are reported under `released_targets`
        there: they have happened either way.) A `cutover_protected` target is refused **409** with `error`
        and `blocked_targets` naming it and its `remedy` (roll it back, which is the only route that returns
        a started cutover to `ordinary`; commit it, which moves it to `promoted`; or run the failover
        job''s cleanup, which retires the operation to `reconciliation_required`); a target whose row
        cannot be read is refused with nothing done. A VM in any other state that blocks removal returns
        409 naming the blocker. If cleanup strands a target, it appears in `GET /api/v1/dr/orphaned-targets`.
        Requires `dr.replication.write`. RETRY: check state before retrying — a replay is NOT unconditionally
        safe. A second detach answers 500, not a no-op. Read the pattern''s VM list before retrying.'
      security: *id005
      parameters:
      - name: pattern_id
        in: path
        required: true
        schema:
          type: string
      - name: vm_context_id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternRemoveVMResponse'
              example:
                message: VM removed from pattern
                pattern_id: pat-0000
                vm_context_id: vmctx-0001
                controller_vm_status: cleanup_queued_or_not_required
        '409':
          description: The VM cannot be removed in its current state; `error` says why and, for a target
            a failover touched, `blocked_targets` names it with its `remedy`. `released_targets`, when
            present, lists committed targets of the VM released on this same request (the membership is
            gone; the refusal concerns another target).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRPatternDetachRefusal'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/sync/jobs/{job_id}:
    get:
      operationId: getDRSyncJob
      summary: Read a sync job
      tags:
      - Replication sync jobs
      description: One replication sync job. Its tenancy is inherited from the replication target it replicates,
        so an out-of-scope job returns **404, not 403**. Requires `dr.replication.read`.
      security: &id006
      - sessionBearer: []
      - providerApiToken: []
      parameters:
      - name: job_id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRSyncJob'
              example:
                id: sync-0000
                replication_target_id: tgt-0000
                sync_type: incremental
                status: running
                progress_percent: 42
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      operationId: cancelDRSyncJob
      summary: Cancel a sync job
      tags:
      - Replication sync jobs
      description: 'Stop a running sync. The terminal state is absorbing, so a repeat call does not undo
        anything. Requires `dr.replication.write`. RETRY: check state before retrying — a replay is NOT
        unconditionally safe. Converges in STATE but not in RESPONSE: a second call answers 409 SYNC_JOB_NOT_CANCELLABLE.
        Read the sync job''s status before retrying.'
      security: *id006
      parameters:
      - name: job_id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRSyncCancelResponse'
              example:
                message: Sync cancelled
                job_id: sync-0000
        '409':
          description: 'The sync job is not in a cancellable state — code `SYNC_JOB_NOT_CANCELLABLE`.
            A second cancel lands here: the operation converges in STATE but not in RESPONSE.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/sync/jobs/{job_id}/progress:
    get:
      operationId: getDRSyncJobProgress
      summary: Sync job progress
      tags:
      - Replication sync jobs
      description: Live progress for a sync job. **This is the documented polling read** for an asynchronous
        sync. Requires `dr.replication.read`.
      security: *id006
      parameters:
      - name: job_id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRSyncProgressResponse'
              example:
                job_id: sync-0000
                status: running
                progress_percent: 42
                bytes_transferred: 10737418240
                total_bytes: 26843545600
                transfer_speed_bps: 62914560
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/sync/jobs/{job_id}/retry:
    post:
      operationId: retryDRSyncJob
      summary: Retry a failed sync job
      tags:
      - Replication sync jobs
      description: 'Start a fresh sync for the same target, carrying the failed job''s context. **An explicit
        retry route, so replay is safe by construction.** Returns **202** with the NEW job; the old id
        is echoed back as `old_job_id`. Requires `dr.replication.write`. RETRY: do NOT replay this call.
        The operation has its own retry route, which is the supported way to try again. This IS the retry
        route for a failed sync job. Replaying the original trigger instead risks a second job against
        the same target.'
      security: *id006
      parameters:
      - name: job_id
        in: path
        required: true
        schema:
          type: string
      x-bodyless-request: true
      responses:
        '202':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRSyncRetryResponse'
              example:
                message: Sync retry triggered
                job:
                  id: sync-0001
                  status: pending
                old_job_id: sync-0000
                target_id: tgt-0000
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/targets:
    get:
      operationId: listDRTargets
      summary: List replication targets
      tags:
      - Replication targets
      description: Replication targets visible to the caller, site-filtered under multi-tenant enforcement.
        Requires `dr.replication.read`.
      security: &id007
      - sessionBearer: []
      - providerApiToken: []
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRTargetListResponse'
              example:
                targets: []
                total: 0
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/targets/{id}:
    get:
      operationId: getDRTarget
      summary: Read a replication target
      tags:
      - Replication targets
      description: 'One replication target with its disks. **This is where the lifecycle identifiers are
        read**: `controller_vm_id` before commit (Sendense infrastructure, not the production VM) and
        the `promoted_*` fields after it. `root_disk_index` is the layout-inferred boot disk — do not
        assume source index 0. An out-of-scope target returns **404, not 403**. Requires `dr.replication.read`.'
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRTargetDetailResponse'
              example:
                target:
                  id: tgt-0000
                  source_vm_name: workload-01
                  status: ready
                disks: []
                root_disk_index: 0
                cpu_compat_status: ok
                cpu_compat_details: ''
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    patch:
      operationId: updateDRTarget
      summary: Update a replication target
      tags:
      - Replication targets
      description: 'Update the declared fields of a replication target. Naturally idempotent. Requires
        `dr.replication.write`. RETRY: naturally idempotent — a second identical call converges on the
        same state and answers the same way. Applies the explicit values in the body. A replay rewrites
        the same values.'
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRTargetUpdateRequest'
            example:
              sync_enabled: true
              sync_interval_minutes: 30
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRTarget'
              example:
                id: tgt-0000
                sync_enabled: true
                sync_interval_minutes: 30
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/targets/{id}/commit:
    post:
      operationId: commitDRTargetFailover
      summary: Commit one VM's failover
      tags:
      - Replication targets
      description: '**Irreversible.** The promoted VM becomes production and the rollback path is discarded.
        The DR controller VM is itself promoted and renamed to the source VM''s name, after which the
        target no longer owns a controller and the `promoted_*` identifiers become readable on the target.
        Requires `dr.replication.write` (applied by the SHA edge to every DR route) PLUS `dr.failover.commit`,
        which is graded by path and separately grantable from live failover. Under multi-tenant enforcement
        this requires a typed `confirm` matching the target''s own `source_vm_name` plus a non-empty `reason`;
        the match is server-side, so echoing the request cannot satisfy it. **Do not replay it blindly.**
        A concurrent replay is refused by the commit lock naming the blocking job. After the commit reaches
        a terminal state a replay is answered from the recorded outcome rather than repeating the promotion
        — with an `Idempotency-Key` as a replay of the original result, and without one as the structured
        409 described below. Resolve a timeout by sending the same `Idempotency-Key` again, or by reading
        `GET .../commit/readiness` and the target''s `status` and `promoted_at` before acting. RETRY:
        replay-safe only with an `Idempotency-Key`. Irreversible: it promotes the replica to production
        and discards the rollback path. Send Idempotency-Key and a retry returns the original outcome
        with replayed=true. WITHOUT a key a retry answers 409 with commit_state and already_committed
        naming the prior terminal outcome, so a lost response is still diagnosable — but only the key
        makes the retry succeed. IDEMPOTENCY-KEY. Send an `Idempotency-Key` request header and a retry
        of the SAME request returns a BOUNDED RECORD of the original outcome with `replayed: true`, instead
        of doing anything. The key is scoped to this operation, this exact resource and your own identity,
        so it can never replay across tenants, sites, targets or patterns; it authorises nothing, and
        it is consulted only AFTER authentication, the permission check, the site gate and the typed confirmation,
        so it cannot bypass any of them — a replay must re-satisfy all four. Reusing a live key with materially
        different content is refused with 409 `IDEMPOTENCY_KEY_REUSED`; a duplicate arriving while the
        first call is still running is refused with 409 `IDEMPOTENCY_KEY_IN_FLIGHT`. If this appliance
        has no key store configured, a request carrying the header is refused with 503 rather than run
        unprotected — you would otherwise believe an irreversible operation was replay-safe when it was
        not. COMMIT REPLAY WITHOUT A KEY. A commit against a target whose failover job already reached
        a terminal commit state answers 409 with a structured body rather than a generic error: `code`,
        `commit_state` (on a conflict body one of `in_progress`, `completed`, `failed`, `not_committable`
        or `no_failover_job`), `already_committed`, `failover_job_id`, `terminal_status` and the promoted
        identifiers the ORIGINAL commit persisted. `already_committed` is the only flag you may read as
        "my irreversible operation happened": a wedged commit (`commit_state: failed`) is ALSO a 409 and
        must never be treated as success. THE REPLAY CARRIES: `message`, `commit_state`, `target_id`,
        `job_id`, `promoted_vm_id`, `promoted_vm_name`, `promoted_cloudstack_instance_name`, `promoted_at`,
        `promoted_rename_status`, `promoted_vm_retained`, `rollback_possible`, `cleanup_incomplete`, plus
        `replayed: true` and the `idempotency_key` you sent. It does NOT carry, although the original
        success response does: `summary`, `promoted_vault_credential_id`, `promoted_rename_error`, `controller_vm_deleted`,
        `removed_from_pattern`, `snapshots_deleted`, `snapshot_delete_failures`, `controller_root_volume_id`,
        `controller_root_volume_status` and `cleanup_warning` — the counters describe ONE execution rather
        than the outcome, and `promoted_vault_credential_id` is a credential-store handle that does not
        belong in a durable retry cache. Read any of those from the FIRST response or from the resource
        itself. IDENTIFIER LIFECYCLE: before commit, `controller_vm_id` is Sendense infrastructure — the
        DR controller VM — and is NOT the final promoted production VM; the promoted identifiers are absent.
        Commit promotes that VM, writes `promoted_vm_id`, `promoted_vm_name` and `promoted_at`, and retires
        controller ownership, so only after commit are the promoted identifiers authoritative. `promoted_cloudstack_instance_name`
        is BEST-EFFORT and may legitimately be absent after a successful commit — on a VMware destination
        it is absent by construction. A replay returns what was persisted; the identifiers are never recreated
        or guessed.'
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      - name: Idempotency-Key
        in: header
        required: false
        description: 'Makes a retried commit exactly-once. A commit that times out at the proxy is indistinguishable
          from one that never ran; with this header a retry of the SAME request returns a bounded record
          of the original outcome with `replayed: true` instead of acting again. Reusing a live key with
          materially different content is refused with 409 `IDEMPOTENCY_KEY_REUSED`; a duplicate arriving
          while the first call is still running is refused with 409 `IDEMPOTENCY_KEY_IN_FLIGHT`. If the
          appliance has no key store configured, a request carrying this header is refused with 503 rather
          than run unprotected. Persist the key BEFORE sending the request — a key you cannot recover
          after a crash protects nothing.'
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRIrreversibleRequest'
            example:
              confirm: workload-01
              reason: cutover approved by change CR-0000
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRCommitResponse'
              example:
                target_id: tgt-0000
                job_id: fo-0001
                promoted_vm_id: vm-0000
                promoted_vm_name: workload-01
                promoted_rename_status: succeeded
                rollback_possible: false
        '400':
          description: 'The typed confirmation is missing or does not match the target''s `source_vm_name`,
            or the `reason` is empty. The body carries `code: CONFIRMATION_REQUIRED` and `confirm_with`
            naming exactly what to type. If the name cannot be resolved server-side the identifier stands
            in as the expected value — the guard never degrades to reason-only.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRConfirmationRefusal'
        '403':
          description: The caller does not hold `dr.failover.commit`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Another operation holds the target's lock, or there is no committable failover
            job — which includes the case where this commit already succeeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/targets/{id}/commit/readiness:
    get:
      operationId: getDRTargetCommitReadiness
      summary: Whether commit is possible
      tags:
      - Replication targets
      description: 'Whether the target can be committed, and why not if it cannot. **This is the mandatory
        probe after a commit request times out**: read it together with the target''s `status` and `promoted_at`
        to establish whether the commit landed. Requires `dr.replication.read`. THIS IS THE DOCUMENTED
        POST-TIMEOUT PROBE. If a commit call timed out, read this instead of retrying an irreversible
        operation: it answers with the same `commit_state` vocabulary and the same promoted identifiers
        a blind retry would produce, so the two cannot disagree. Read `never_started` carefully — it describes
        the COMMIT lifecycle, not the failover''s, and means a failover HAS run and is awaiting commit.
        The state that means nothing ran is `no_failover_job`. Treating `never_started` as "my failover
        did not happen" and retrying would start a SECOND failover of a production VM.'
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRCommitReadinessResponse'
              example:
                can_commit: false
                reason: No failover awaiting commit
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/targets/{id}/disks:
    get:
      operationId: listDRTargetDisks
      summary: List a target's disks
      tags:
      - Replication targets
      description: The target's replica and controller disks, carrying the destination `volume_id` and
        `destination_disk_id` an orchestrator needs. Requires `dr.replication.read`.
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRDiskListResponse'
              example:
                disks: []
                total: 0
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      operationId: addDRTargetDisk
      summary: Add a disk to a target
      tags:
      - Replication targets
      description: 'Create and attach an additional replica disk. **Not safe to replay.** This operation
        takes no `Idempotency-Key` — only the two irreversible commits and tenant creation do — and the
        operation lock is mutual exclusion rather than identity — it rejects a CONCURRENT second request
        and releases on completion. Requires `dr.replication.write`. RETRY: UNSAFE TO REPLAY — a second
        call performs the work again. Each call creates AND attaches a new volume on the destination.
        Nothing deduplicates by source disk id, so a replay leaves the target carrying a second volume
        that nothing will reclaim.'
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRDiskCreateRequest'
            example:
              source_disk_index: 1
              size_bytes: 107374182400
      responses:
        '201':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRDiskCreateResponse'
              example:
                message: Disk created and attached successfully
                disk:
                  id: disk-0000
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/targets/{id}/failover:
    post:
      operationId: triggerDRTargetFailover
      summary: Fail over one VM
      tags:
      - Replication targets
      description: 'Cut over one VM. **The permission is graded by `failover_type`, in the handler rather
        than the middleware, because the type is in the BODY**: `test` requires `dr.failover.test` and
        is reversible; `planned` and `unplanned` require `dr.failover.live` and power off the production
        source VM. Live and planned additionally require typed confirmation. Under multi-tenant enforcement
        this requires a typed `confirm` matching the target''s own `source_vm_name` plus a non-empty `reason`;
        the match is server-side, so echoing the request cannot satisfy it. Returns **202** with the failover
        job. **Not safe to replay.** This operation takes no `Idempotency-Key` — only the two irreversible
        commits and tenant creation do — and the operation lock is mutual exclusion rather than identity
        — it rejects a CONCURRENT second request and releases on completion. Never replay a live cutover
        blindly — read `GET .../failover/jobs` first. RETRY: check state before retrying — a replay is
        NOT unconditionally safe. A live or planned failover is refused with 409 OPERATION_BLOCKED while
        the target''s operation lock is held, and a test failover can be repeated, but once the lock releases
        a replay starts a SECOND failover. Read GET /api/v1/dr/targets/{id}/commit/readiness before retrying
        and treat ONLY commit_state=no_failover_job as "nothing ran". Do NOT read never_started that way:
        it means a failover HAS run and is awaiting commit, which is exactly the state a completed failover
        reaches.'
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRFailoverRequest'
            example:
              failover_type: test
              test_network_id: net-0002
      responses:
        '202':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRFailoverTriggerResponse'
              example:
                message: Failover started
                job:
                  id: fo-0000
                  failover_type: test
                  status: pending
        '400':
          description: The failover type is invalid, or the typed confirmation or reason is missing or
            does not match the target's source VM name.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller holds neither `dr.failover.test` nor `dr.failover.live` as required
            by the requested type.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: A sync checkpoint requires recovery, or another operation holds the target's lock.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: Failover is unavailable until a destination cloud is configured.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/targets/{id}/failover/jobs:
    get:
      operationId: listDRTargetFailoverJobs
      summary: List a target's failover jobs
      tags:
      - Replication targets
      description: Failover jobs for one target. **This is the target-anchored way to locate a failover
        job by id** for provider automation. Requires `dr.replication.read`.
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRFailoverJobListResponse'
              example:
                jobs: []
                total: 0
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/targets/{id}/failover/readiness:
    get:
      operationId: getDRTargetFailoverReadiness
      summary: Whether failover is possible
      tags:
      - Replication targets
      description: Whether the target is ready to fail over, and why not if it is not. Requires `dr.replication.read`.
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRFailoverReadinessResponse'
              example:
                ready: true
                reason: Target is ready for failover
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/targets/{id}/health:
    get:
      operationId: getDRTargetHealth
      summary: Target replica health
      tags:
      - Replication targets
      description: Replica validation and health state for a target. Requires `dr.replication.read`.
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRTargetHealthResponse'
              example:
                target_id: tgt-0000
                healthy: true
                disks: []
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/targets/{id}/health/acknowledge:
    post:
      operationId: acknowledgeDRTargetHealth
      summary: Acknowledge a target health error
      tags:
      - Replication targets
      description: 'Acknowledge a replica health error so it stops being reported as new. Naturally idempotent.
        Requires `dr.replication.write`. RETRY: naturally idempotent — a second identical call converges
        on the same state and answers the same way. Acknowledging already-acknowledged health state is
        a no-op.'
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      x-bodyless-request: true
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRTargetMessageResponse'
              example:
                message: Health error acknowledged
                target_id: tgt-0000
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/targets/{id}/provision/retry:
    post:
      operationId: retryDRTargetProvisioning
      summary: Retry controller provisioning
      tags:
      - Replication targets
      description: 'Retry provisioning of the target''s DR controller. **An explicit retry route, so replay
        is safe by construction.** Returns **202**. Requires `dr.replication.write`. RETRY: do NOT replay
        this call. The operation has its own retry route, which is the supported way to try again. This
        IS the retry route for failed controller provisioning. When a stale target for the same VM has
        to be torn down first, this call answers **409** if that target’s `cutover_safety_state` is not
        `ordinary`; nothing is destroyed and no replacement is provisioned.'
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      x-bodyless-request: true
      responses:
        '202':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRTargetProvisionRetryResponse'
              example:
                message: Provisioning retry started
                target_id: tgt-0000
        '409':
          description: 'The target’s destination workload may not be deleted by an ordinary cleanup path.
            Nothing was done to the target: no platform call was made, its status was not changed, and
            no cleanup lock was left held. `code` and `remedy` name the state and its exit.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRTargetDestroyRefusal'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/targets/{id}/rollback:
    post:
      operationId: rollbackDRTargetFailover
      summary: Roll back one VM's failover
      tags:
      - Replication targets
      description: '**Destructive.** Revert a failover: power the source VM back on and **destroy the
        promoted destination copy**. There is no second rollback to undo it. Requires `dr.replication.write`
        (applied by the SHA edge to every DR route) PLUS `dr.rollback`, which is graded by path in the
        middleware and separately grantable from failover — a caller holding only `dr.replication.write`
        is refused with **403**. Under multi-tenant enforcement this requires a typed `confirm` plus a
        non-empty `reason`. The match is SERVER-SIDE against the resource''s own authoritative name, so
        echoing the request back cannot satisfy it, and the check runs BEFORE any rollback mutation begins.
        A refusal is **400** with `code: CONFIRMATION_REQUIRED` and `confirm_with` naming exactly what
        to type. If the name cannot be resolved the guard does NOT soften to reason-only: the identifier
        stands in as the expected value. The audit actor is derived from your authenticated identity and
        can never be supplied in the request body. The confirmation value is the target''s own `source_vm_name`.
        Returns **202** with the rollback job; the rollback itself runs asynchronously, so poll `GET /api/v1/dr/failover/jobs/{job_id}`
        for its outcome. **Not safe to replay.** This operation takes no `Idempotency-Key` — only the
        two irreversible commits and tenant creation do — and the operation lock is mutual exclusion rather
        than identity: it rejects a CONCURRENT second request and releases on completion. RETRY: check
        state before retrying — a replay is NOT unconditionally safe. Refused with 409 OPERATION_BLOCKED
        while the lock is held; after release a replay starts a second rollback. Read the failover job''s
        status first.'
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRIrreversibleRequest'
            example:
              confirm: workload-01
              reason: rehearsal complete
      responses:
        '202':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRRollbackTriggerResponse'
              example:
                message: Rollback started
                failover_job_id: fo-0001
                failover_type: planned
        '400':
          description: 'Two causes. Either there is no rollback-eligible failover for this target — a
            test failover must be `completed` and a live one `pending_commit` or `completed` — which is
            checked FIRST and answers a plain error; or the typed confirmation is missing or does not
            match the target''s `source_vm_name`, or the `reason` is empty, which carries `code: CONFIRMATION_REQUIRED`
            and `confirm_with`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRRollbackTargetRefusal'
        '403':
          description: The caller does not hold `dr.rollback`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: The target is outside the caller's authorised scope, or does not exist. The two
            are deliberately indistinguishable.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: 'Another operation holds the target''s lock (`OPERATION_BLOCKED`, naming the blocking
            job). The operation lock is mutual exclusion, not identity: wait for it to release and read
            the failover job''s status before deciding whether to send the rollback again.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRRollbackTargetConflict'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/targets/{id}/schedule:
    get:
      operationId: getDRTargetSchedule
      summary: Read a target's sync schedule
      tags:
      - Replication targets
      description: The target's sync schedule and its last and next sync times. Requires `dr.replication.read`.
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRScheduleResponse'
              example:
                target_id: tgt-0000
                sync_enabled: true
                sync_interval_minutes: 60
                last_sync_at: null
                next_sync_at: null
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    put:
      operationId: updateDRTargetSchedule
      summary: Replace a target's sync schedule
      tags:
      - Replication targets
      description: 'Replace the target''s sync schedule. Naturally idempotent. Requires `dr.replication.write`.
        RETRY: naturally idempotent — a second identical call converges on the same state and answers
        the same way. A full replacement of the schedule. A replay writes the same schedule.'
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRScheduleUpdateRequest'
            example:
              sync_enabled: true
              sync_interval_minutes: 30
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRTargetMessageResponse'
              example:
                message: Schedule updated
                target_id: tgt-0000
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/targets/{id}/sync:
    post:
      operationId: triggerDRTargetSync
      summary: Trigger a sync for one target
      tags:
      - Replication targets
      description: 'Start a full or incremental sync for one VM. Returns **202** with the job; poll `GET
        /api/v1/dr/sync/jobs/{job_id}/progress`. **Not safe to replay.** This operation takes no `Idempotency-Key`
        — only the two irreversible commits and tenant creation do — and the operation lock is mutual
        exclusion rather than identity — it rejects a CONCURRENT second request and releases on completion.
        A concurrent replay is refused by the operation lock naming the blocking job; a replay after it
        releases starts a second sync. Requires `dr.replication.write`. RETRY: check state before retrying
        — a replay is NOT unconditionally safe. While a sync holds the target''s operation lock a replay
        answers 409 OPERATION_BLOCKED, but the lock releases on completion — after which a replay starts
        a second sync. Read GET /api/v1/dr/targets/{id}/sync/summary before retrying.'
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRSyncTriggerRequest'
            example:
              sync_type: incremental
      responses:
        '202':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRSyncTriggerResponse'
              example:
                message: Sync triggered
                job:
                  id: sync-0000
                  sync_type: incremental
                  status: pending
        '409':
          description: The target has another operation in flight; the body names the blocking job and
            operation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/targets/{id}/sync/jobs:
    get:
      operationId: listDRTargetSyncJobs
      summary: List a target's sync jobs
      tags:
      - Replication targets
      description: Sync jobs for one target, newest first. Requires `dr.replication.read`.
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      - name: limit
        in: query
        required: false
        description: Page size.
        schema:
          type: string
      - name: cursor
        in: query
        required: false
        description: Opaque cursor from `next_cursor`.
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRSyncJobListResponse'
              example:
                jobs: []
                total: 0
                next_cursor: ''
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/targets/{id}/sync/summary:
    get:
      operationId: getDRTargetSyncSummary
      summary: Sync summary for one target
      tags:
      - Replication targets
      description: Counts, transferred bytes and the last job for a target over a period. Requires `dr.replication.read`.
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      - name: period
        in: query
        required: false
        description: Reporting period, e.g. 7d.
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRSyncSummaryResponse'
              example:
                target_id: tgt-0000
                period: 7d
                window:
                  start: '2026-08-13T00:00:00Z'
                  end: '2026-08-20T00:00:00Z'
                counts:
                  completed: 0
                  failed: 0
                  running: 0
                  pending: 0
                  cancelled: 0
                bytes_transferred: 0
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/targets/{id}/validate:
    post:
      operationId: validateDRTarget
      summary: Validate a target's replicas
      tags:
      - Replication targets
      description: 'Run replica validation for a target. The result is also durable on the disks and readable
        through `GET .../health`. Requires `dr.replication.write`. RETRY: check state before retrying
        — a replay is NOT unconditionally safe. Starts a disk validation run. A replay starts another
        one against the same target rather than returning the running one, so read the target''s validation
        state first.'
      security: *id007
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DRValidateRequest'
            example:
              check_type: quick
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRValidateResponse'
              example:
                target_id: tgt-0000
                check_type: quick
                results: []
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/dr/vm-replication-status:
    get:
      operationId: listDRVMReplicationStatus
      summary: Replication posture across VMs
      tags:
      - DR replication posture
      description: Replication posture for the caller's VMs — which are protected, by which pattern, and
        how current. Site-filtered under multi-tenant enforcement. Requires `dr.replication.read`.
      security:
      - sessionBearer: []
      - providerApiToken: []
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DRVMReplicationStatusResponse'
              example:
                statuses: []
                total: 0
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/licensing/assignments:
    get:
      operationId: listLicenseAssignments
      summary: List workload licence assignments
      tags:
      - Licensing
      description: Workload licence assignments, PROVIDER-WIDE across every tenant on the appliance. Requires
        `settings.read`, and the route is staff-only so a site-scoped caller is refused with 404 under
        site-scope enforcement. Ordered newest first (`created_at DESC, id DESC`). Unbounded by default
        for compatibility — supply `limit`/`offset` to page a large estate, and read `total` to know when
        to stop. The window never widens the `state`/`lineage_id` filters, and `total` counts the filtered
        set, not the table.
      security: &id008
      - sessionBearer: []
      - providerApiToken: []
      parameters:
      - name: state
        in: query
        required: false
        description: Filter by state (case-insensitive).
        schema:
          type: string
      - name: lineage_id
        in: query
        required: false
        description: Filter by workload lineage id.
        schema:
          type: string
      - name: limit
        in: query
        required: false
        description: Optional page size, 1-1000. ABSENT MEANS UNBOUNDED. A malformed or out-of-range value
          is a 400, never a silent fallback to the whole ledger.
        schema:
          type: string
      - name: offset
        in: query
        required: false
        description: 'Optional rows to skip, 0-1000000. REQUIRES `limit`: an offset with no page size
          cannot be expressed on this appliance''s database and is refused with 400 rather than served.
          Applied after the filters and after the deterministic ordering (`created_at DESC, id DESC`,
          a total order because `id` is unique), so pages neither overlap nor skip.'
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AssignmentsListResponse'
        '500':
          description: Failed to list assignments.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: The licence assignment/reconciliation tables are not present on this appliance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '400':
          description: Malformed or out-of-range `limit`/`offset`, or an `offset` supplied without a `limit`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/licensing/status:
    get:
      operationId: getLicenseStatus
      summary: Get licence status
      tags:
      - Licensing
      description: 'The appliance''s licence status, entitlements, verification, term/currency state,
        composed pool, capacity position and consumption insights. Requires `settings.read` (a human session
        or a provider service credential; the route is staff-only, so a site-scoped caller is refused
        with 404 under site-scope enforcement).


        This is the provider''s licensing answer in one read: `pool` for capacity, `current_protected_vms`
        and `documents[].held_capacity` for consumption, `over_limit`, `term_state` and `currency_state`
        for position, `feature_entitlements` for the multi-tenant entitlement, `reconciliation` for the
        capacity fuse, and `insights` for where the capacity went, including the only tenant-attributed
        figures the API publishes.


        DEGRADED STATE, NOT AN ERROR: licence-server unreachability does not fail this route. It returns
        200 with `central_verification.status=UNREACHABLE` and a `failure_code`; the upstream error text
        is never echoed. Poll it rather than treating the first UNREACHABLE as an outage.'
      security: *id008
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LicenceStatus'
        '500':
          description: Failed to load the licence status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/provider-tokens:
    get:
      operationId: listProviderTokens
      summary: List provider API credentials
      tags:
      - Provider API credentials
      description: 'Every provider credential on the appliance. Never returns a token value or hash —
        only the safe projection, including a `token_prefix` for recognition and the revocation, expiry
        and last-use metadata that makes a forgotten credential visible rather than merely old. Requires
        a human session: a provider service credential cannot read this family.'
      security: &id009
      - sessionBearer: []
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProviderTokenListResponse'
              example:
                provider_tokens:
                - id: ptok-3f9c2a7b41d84e6fa0b5c8d213e47f96
                  name: provider-example migration orchestrator
                  description: Server-side automation that onboards tenants and drives cutovers
                  token_prefix: a1b2c3d4e5f60718
                  scope: provider
                  permissions:
                  - settings.write
                  - settings.read
                  - users.write
                  - dr.replication.read
                  never_expires: false
                  expires_at: '2027-02-14T09:00:00Z'
                  last_used_at: '2026-08-19T02:00:00Z'
                  created_by_user_id: usr-provider-admin
                  created_at: '2026-08-18T09:00:00Z'
                total: 1
        '503':
          description: The provider-credential feature is unavailable on this appliance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller is a service credential rather than a human administrator, or lacks
            `settings.write`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      operationId: mintProviderToken
      summary: Mint a provider API credential
      tags:
      - Provider API credentials
      description: 'Create an appliance-wide provider credential. The response carries the clear `token`
        exactly once — store it now; it cannot be retrieved again. Exactly one of `expires_in_days` and
        `never_expires` must be supplied: omitting both is refused, because a permanent appliance-wide
        credential must be a deliberate choice rather than the result of leaving a field out. Requested
        `permissions` are intersected with the minting administrator''s own, so nobody can mint a credential
        wider than themselves, and at least one must survive that intersection. Requires a human session
        (`sessionBearer`): any non-user subject is refused 403, so a service credential cannot issue a
        successor that outlives revoking the original. A **site-bounded** administrator is also refused:
        permissions are intersected with the minter''s own, but site reach cannot be — this credential
        carries no site list, so a caller who is themselves denied another tenant''s site must not be
        able to mint one that reaches it. Recorded fail-closed in the audit trail before the credential
        is created.'
      security:
      - sessionBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProviderTokenMintRequest'
            example:
              name: provider-example migration orchestrator
              description: Server-side automation that onboards tenants and drives cutovers
              permissions:
              - settings.write
              - settings.read
              - users.write
              - dr.replication.read
              expires_in_days: 180
      responses:
        '201':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProviderTokenMintResponse'
              example:
                token: <the clear credential, shown once and never retrievable>
                warning: 'This is the only time the token is shown. It is stored as a hash and cannot
                  be retrieved again — if it is lost, revoke it and mint another. This credential reaches
                  every tenant on this appliance: keep it server-side and never issue it to a tenant or
                  place it in a browser.'
                provider_token:
                  id: ptok-3f9c2a7b41d84e6fa0b5c8d213e47f96
                  name: provider-example migration orchestrator
                  token_prefix: a1b2c3d4e5f60718
                  scope: provider
                  permissions:
                  - settings.write
                  - settings.read
                  - users.write
                  - dr.replication.read
                  never_expires: false
                  expires_at: '2027-02-14T09:00:00Z'
                  last_used_at: null
                  created_by_user_id: usr-provider-admin
                  created_at: '2026-08-18T09:00:00Z'
        '400':
          description: The request is rejected — for example neither `expires_in_days` nor `never_expires`
            was supplied, both were, `name` is missing, or the requested permissions leave nothing to
            grant after intersection with the caller's.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: The provider-credential feature is unavailable on this appliance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/provider-tokens/{id}:
    get:
      operationId: getProviderToken
      summary: Read a provider API credential
      tags:
      - Provider API credentials
      description: 'One provider credential by id. Never returns the token value or its hash. Requires
        a human session: a provider service credential cannot read this family.'
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProviderTokenResponse'
              example:
                provider_token:
                  id: ptok-3f9c2a7b41d84e6fa0b5c8d213e47f96
                  name: provider-example migration orchestrator
                  token_prefix: a1b2c3d4e5f60718
                  scope: provider
                  permissions:
                  - settings.write
                  - settings.read
                  - users.write
                  - dr.replication.read
                  never_expires: false
                  expires_at: '2027-02-14T09:00:00Z'
                  last_used_at: '2026-08-19T02:00:00Z'
                  created_by_user_id: usr-provider-admin
                  created_at: '2026-08-18T09:00:00Z'
        '503':
          description: The provider-credential feature is unavailable on this appliance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller is a service credential rather than a human administrator, or lacks
            `settings.write`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      operationId: revokeProviderToken
      summary: Revoke a provider API credential
      tags:
      - Provider API credentials
      description: 'Permanently revoke a provider credential by id. A `reason` is required — it is the
        record of why the credential was killed. Revoking never touches the administrator account that
        created it, which is the property the whole credential class exists for: provider automation can
        be stopped without disabling a human login. Revocation takes effect on the very next request.
        Safe to retry: the update matches only an unrevoked row, so a replay reports `no such active token`
        rather than overwriting the original revocation''s actor, reason or timestamp. Requires a human
        session: a provider service credential cannot revoke provider credentials, including its own —
        a leaked credential must not be able to kill the incident response that would contain it. Recorded
        fail-closed in the audit trail before the revocation is applied.'
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProviderTokenRevokeRequest'
            example:
              reason: orchestrator decommissioned
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProviderTokenRevokeResponse'
              example:
                status: revoked
        '400':
          description: No `reason` was supplied, or there is no active credential with that id (a replayed
            revoke lands here).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: The provider-credential feature is unavailable on this appliance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller is a service credential rather than a human administrator, or lacks
            `settings.write`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such resource.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/sites:
    get:
      operationId: listSites
      summary: List sites
      tags:
      - Sites
      description: All sites the caller can see, with a delegation-aware appliance count and a computed
        health `status`. This shape carries `status`, which the detail and create shapes do not; `tenant_id`
        is on the list and the detail alike. The `sites` array is never null (empty at most). Requires
        `settings.read` (under enforced multi-tenancy `dr.replication.read` or `inventory.read` also admits
        it). The provider credential and an administrator see the whole estate; a site-scoped caller sees
        only its own sites — an empty binding list yields an empty list, never the estate. `tenant_id`
        is `null` for an ungrouped site. No filters and no pagination.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SiteListResponse'
              example:
                sites:
                - id: ldn
                  name: London
                  description: Primary site
                  location: UK
                  site_type: production
                  status: healthy
                  is_sna_pool: false
                  assume_file_based_storage: false
                  sna_delegate_site_id: null
                  tenant_id: null
                  appliance_count: 2
                  created_at: '2026-07-01T09:00:00Z'
                  updated_at: '2026-08-13T08:00:00Z'
                total: 1
        '500':
          description: The sites could not be read (plain-text body).
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      operationId: createSite
      summary: Create a site
      tags:
      - Sites
      description: 'Create a site. Only `name` is required, and the blank check is EXACT, so a whitespace-only
        name is accepted with a 201 — trim your own input. The id is a server-assigned UUID. Site `name`
        carries a UNIQUE constraint appliance-wide (`uk_site_name`), and the handler maps every create
        error to a **500 with a plain-text body**, so a duplicate name is neither a success nor a 409:
        it is a 500. There is no idempotency key here, so read your own records rather than blind-retrying
        a lost response. The response is a MINIMAL 7-field shape — not the list or detail shape.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SiteCreateRequest'
            example:
              name: New York
              description: East-coast site
              location: US
      responses:
        '201':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SiteCreateResult'
              example:
                id: 6f1e...
                name: New York
                description: East-coast site
                location: US
                appliance_count: 0
                created_at: '2026-08-13T09:00:00Z'
                updated_at: '2026-08-13T09:00:00Z'
        '400':
          description: An empty name or a malformed body (plain-text body). A whitespace-only name is
            NOT rejected here.
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: The create failed server-side (plain-text body). This is also what a DUPLICATE
            site name answers, because `uk_site_name` is unique appliance-wide and the handler does not
            distinguish the constraint violation.
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/sites/{id}:
    get:
      operationId: getSite
      summary: Get a site
      tags:
      - Sites
      description: 'One site by id, with `tenant_id` — the tenant that groups it, or `null` for an ungrouped
        site (one no tenant owns: a shared or provider-operated site, or one left behind by a tenant delete).
        Relate a site to its tenant from this binding, never from its name; it is grouping and reporting
        data and is never an authorisation input. Requires `settings.read` (under enforced multi-tenancy
        `inventory.read` also admits the read). Gated per site: another tenant''s site, like a missing
        one, answers 404. This DETAIL shape omits `status` and its `appliance_count` is a raw count (not
        delegation-aware) — it differs from the list shape in those two respects.'
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Site'
              example:
                id: ldn
                name: London
                description: Primary site
                location: UK
                site_type: production
                is_sna_pool: false
                assume_file_based_storage: false
                sna_delegate_site_id: null
                appliance_count: 2
                created_at: '2026-07-01T09:00:00Z'
                updated_at: '2026-08-13T08:00:00Z'
                tenant_id: null
        '404':
          description: No such site, or out of the caller's scope. A site-scoped caller gets a JSON body
            from the scope gate; a request that reaches the handler (scoping off, or a scope-bypassing
            admin/service caller) gets a plain-text body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
            text/plain:
              schema:
                type: string
        '500':
          description: The site could not be read (plain-text body).
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      operationId: deleteSite
      summary: Delete a site
      tags:
      - Sites
      description: Delete a site. Refused with 409 when the site is the reserved SNA-pool site or still
        has dependents (appliances, protected VMs, credentials, sources, delegating sites, physical machines,
        users, and so on) — clear the named dependents, then retry. An ABSENT id answers a JSON 404 from
        the route gate in every scoping mode and for every caller class — the gate resolves the site before
        any mode or caller check — so a replay after a successful delete is 404. Requires `settings.write`.
        Deleting a site removes its discovered inventory (VM contexts) with it; it is refused 409 while
        anything in the site is still protected, and the reserved SNA-pool site cannot be deleted. After
        a tenant delete the tenant's sites survive ungrouped — this is the separate act that removes them.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SiteDeleteResult'
              example:
                message: Site deleted successfully
        '404':
          description: No such site (including a replay after a successful delete), or — for a site-scoped
            caller — a site outside its scope. JSON body from the route gate.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: 'The site is reserved (the SNA pool), or still has dependents blocking deletion:
            protected VMs (named, with the real remedy — remove them from their replication pattern, remove
            orphaned replication targets, delete or expire backups), orphaned replication targets, appliances,
            credentials, sources, delegating sites, physical machines, users. Plain-text body naming them.'
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: The delete failed server-side (plain-text body).
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/tenants:
    get:
      operationId: listTenants
      summary: List tenants
      tags:
      - Tenants
      description: 'Every tenant on the appliance with its site and login counts. Requires `settings.read`.
        Estate-wide and STAFF-ONLY: under enforced multi-tenancy a tenant-scoped caller is refused (403
        for lacking `settings.read`, and 404 from the staff gate even if it held it); the provider credential
        reaches it as staff. No filters and no pagination — every tenant comes back in one `tenants` array
        with `total`. This is the reconciliation read an orchestrator uses after a lost create response
        or a restart (CSPAPI-026).'
      security: &id010
      - sessionBearer: []
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TenantListResponse'
              example:
                tenants:
                - id: t_01H8ACME
                  name: Acme Corp
                  status: active
                  site_count: 1
                  user_count: 1
                total: 1
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      operationId: createTenant
      summary: Provision a tenant
      tags:
      - Tenants
      description: Creates the tenant, its sites and its logins in one all-or-nothing transaction. With
        ?dry_run=true the plan is returned and nothing is written — the review step of the drawer is that
        plan, and the apply re-plans inside its own transaction, so what is approved is what executes.
        Generated passwords are returned EXACTLY once, in the 201 body; the appliance stores only bcrypt
        hashes and cannot reproduce them.
      security: *id010
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: 'Makes a retried create exactly-once. A create that times out at the proxy is indistinguishable
          from one that never ran; without this header the retry provisions a second tenant with a second
          set of one-time credentials. A repeat with the same key returns 200 with replayed=true and NO
          credentials — they existed only in the original 201 and cannot be reproduced. IMPORTANT, AND
          DIFFERENT FROM THE DR COMMITS: this key is matched on the KEY ALONE. The request body is NOT
          fingerprinted, so reusing a key for a materially DIFFERENT tenant does not answer 409 — it replays
          the ORIGINAL tenant and silently creates nothing. Use a fresh key per tenant, and read tenant_id
          off the response rather than assuming it is the one you asked for. The key is also APPLIANCE-WIDE
          and shared by every caller — there is no per-credential or per-actor scoping, so a key any administrator
          already used replays THAT tenant. Generate a UUID per attempt.'
        schema:
          type: string
      - name: dry_run
        in: query
        required: false
        description: Return the plan without writing. Accepts 1/true/yes/on and 0/false/no/off; any other
          value is refused rather than guessed.
        schema:
          type: boolean
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TenantCreateRequest'
            example:
              tenant_id: acme-2026-prod
              name: Acme Corp
              description: Acme production tenant
              sites:
              - site_id: site-london
                name: London
              users:
              - email: admin@acme.example
                full_name: Acme Admin
                role: tenant-replication-admin
      responses:
        '201':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TenantApplyResult'
        '200':
          description: Replay of an earlier create carrying the same Idempotency-Key (replayed=true, no
            credentials), or the plan when dry_run is set.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TenantApplyResult'
        '409':
          description: The tenant cannot be provisioned as specified. The body carries the structured
            conflict list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TenantConflictResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/tenants/{id}:
    get:
      operationId: getTenant
      summary: Read a tenant
      tags:
      - Tenants
      description: The tenant with its sites (and their SNA-pool attachment), the logins bound to them
        (roles and site bindings) and its active site-scoped credentials by identity — never a secret.
        This is the read-back an edit is diffed against and the inventory an offboarding plan is checked
        against. Requires `settings.read`; staff-only under enforced multi-tenancy like the list. An unknown
        id answers 404 `{"error":"tenant not found"}`.
      security: *id010
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TenantDetail'
        '404':
          description: No such tenant.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TenantErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      operationId: deleteTenant
      summary: Delete a tenant
      tags:
      - Tenants
      description: 'Delete a tenant. Requires `settings.write`; staff-only under enforced multi-tenancy
        (a tenant-scoped identity cannot delete any tenant, its own included). **Plan first:** `?dry_run=true`
        returns the `TenantDeletePlan` and writes nothing. The apply re-plans inside its own transaction,
        so what was previewed is what executes. **Refusals:** while any owned site still holds a protected
        VM — judged from evidence (replication targets, pattern membership, backups, stored recovery points,
        jobs, work in flight), never from a flag — the plan carries `conflicts[]` and a real delete answers
        **409** with the same conflicts, each naming the site, the VMs and the remedy: remove them from
        their replication pattern (or delete the pattern — `deleteDRPattern`, which always destroys the
        controller VMs of the targets it owns), remove any orphaned replication targets, delete or expire
        their backups. An ambiguous `dry_run` value is refused **400** rather than guessed. **Also refused
        409** — with `field: tenancy` or `tenant_id` rather than `remove_sites` — when the appliance''s
        tenancy preflight fails (the multi-tenant entitlement is not licensed or not activated): offboarding
        runs through the same planner as editing, and an appliance that cannot be edited as multi-tenant
        cannot offboard either until its licence is restored; and 409 `{error, appliance_id, impact}`
        when an appliance on the tenant''s sites still has work in flight. **What a successful delete
        does:** ungroups every owned site (`sites_ungrouped`) and detaches its SNA-pool delegation (`pool_detached`)
        — the site rows AND their discovered inventory survive, now ungrouped; destroys the tenant''s
        site-scoped configuration through its owning services (`cascade_deletes`, `credentials_deleted`,
        `appliances_deleted`); revokes every site API token whose scope touches the tenant''s sites (`site_tokens_revoked`
        — a token left alive would re-arm when an ungrouped site is adopted by another tenant); deletes
        logins whose entire binding estate lay inside the tenant (`logins_deleted`, each with its own
        `user_deleted` audit row and session revocation) and keeps logins also bound elsewhere minus their
        in-tenant bindings (`logins_kept`). Typed `security_audit_log` rows: `tenant.site_ungrouped`,
        `tenant.site_cascaded`, `tenant.deleted`, with the calling credential as actor. **Replay:** a
        second delete answers 404 — the tenant is gone; the sites are not, and deleting them (`deleteSite`)
        is the separate act that removes their inventory.'
      security: *id010
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      - name: dry_run
        in: query
        required: false
        description: 'Return the plan without writing. Accepted: `1`, `true`, `yes`, `on` / `0`, `false`,
          `no`, `off` (also spelled `dry-run` or `dryRun`). Any other value is refused with 400.'
        schema:
          type: boolean
      responses:
        '200':
          description: The delete result, or — when `dry_run` is set — the plan.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TenantDeleteOutcome'
              example:
                tenant_id: t_01H8ACME
                name: Acme Corp
                sites_ungrouped:
                - site-acme-src
                - site-acme-dr
                pool_detached:
                - site-acme-src
                - site-acme-dr
                cascaded_site_ids:
                - site-acme-src
                - site-acme-dr
                cascade_deletes:
                - site_id: site-acme-src
                  label: site-scoped vault credentials
                  count: 1
                - site_id: site-acme-dr
                  label: site-scoped vault credentials
                  count: 1
                logins_deleted: 1
                logins_kept: 0
                appliances_deleted: 0
                credentials_deleted: 2
                site_tokens_revoked: 0
        '400':
          description: '`dry_run` carried a value the appliance refuses to guess at.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TenantErrorResponse'
        '404':
          description: No such tenant (including a replay after a successful delete).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TenantErrorResponse'
        '409':
          description: 'The tenant cannot be deleted as specified: `conflicts[]` names each site that
            still holds protected VMs and the remedy (`field: remove_sites`), or a tenancy-preflight failure
            (`field: tenancy` / `tenant_id` — the multi-tenant entitlement is not licensed or activated).
            A plan-to-apply race returns a bare `error`; an appliance with work in flight returns `{error,
            appliance_id, impact}`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TenantConflictResponse'
        '503':
          description: The delete planner or one of its cascade services is not wired on this appliance;
            nothing was written.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TenantErrorResponse'
        '500':
          description: 'A cascade step failed mid-way (a login, appliance or credential could not be removed
            through its owning service): the tenant is intact and the delete is retryable — the plan is
            recomputed on retry.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TenantErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/users:
    get:
      operationId: listUsers
      summary: List users
      tags:
      - Users
      description: All users with their roles and effective permissions. `full_name` and `last_login`
        are null when unset; `permissions` is null if it could not be resolved. Requires `users.read`,
        which no tenant role carries — the list is estate-wide and unfiltered by design. For who is bound
        to which of a tenant's sites, read the tenant (`getTenant`); for one login's bindings, `getUserSites`.
        No filters and no pagination.
      security: &id011
      - sessionBearer: []
      - providerApiToken: []
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserListResponse'
              example:
                users:
                - id: u_01H8ADMIN
                  email: admin@acme.example
                  full_name: Acme Admin
                  status: active
                  roles:
                  - admin
                  permissions:
                  - inventory.read
                  last_login: '2026-08-13T08:00:00Z'
                  created_at: '2026-07-01T09:00:00Z'
                  updated_at: '2026-08-13T08:00:00Z'
        '500':
          description: The users could not be read.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      operationId: createUser
      summary: Create a user
      tags:
      - Users
      description: Create a user with an email, a password and optional roles. The password is an input
        only — no credential is returned. Each role name must exist. A blank email or password, an unknown
        role, or a duplicate email are all rejected with 400 (there is no 409).
      security: *id011
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserCreateRequest'
            example:
              email: operator@acme.example
              password: ••••••••
              full_name: Acme Operator
              roles:
              - operator
      responses:
        '201':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserCreateResult'
              example:
                id: u_01H8OP
                email: operator@acme.example
                full_name: Acme Operator
                status: active
                created_at: '2026-08-13T09:00:00Z'
        '400':
          description: 'Invalid request: blank email/password, unknown role, duplicate email, or a malformed
            body.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/users/{id}:
    delete:
      operationId: deleteUser
      summary: Delete a user
      tags:
      - Users
      description: Delete a user. Deleting yourself is permitted; deleting the last administrator is refused
        with 400. Deleting an absent user also returns 400 (not idempotent, not 404). Deletion revokes
        the user's refresh sessions, but an already-issued access token keeps working until it expires
        (operator-configurable, default 15 minutes) — deletion is not an immediate cut-off. Requires `users.write`.
        A tenant delete already removes logins whose whole binding estate lay inside the tenant; this
        is for the logins it kept (those also bound elsewhere) once they are no longer wanted.
      security: *id011
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
              example:
                success: true
        '400':
          description: The user does not exist, or removing it would leave no administrator.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/users/{id}/sites:
    get:
      operationId: getUserSites
      summary: Get a user's sites
      tags:
      - Users
      description: The site ids the user is assigned to, as bare strings. `site_ids` is always an array
        (empty, never null). An unknown user id returns 200 with an empty list rather than 404. Requires
        `users.read`. The direct read-back of `setUserSites` after a lost response.
      security: *id011
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserSitesResponse'
              example:
                user_id: u_01H8OP
                site_ids:
                - ldn
        '503':
          description: Site assignment is unavailable on this appliance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: The user's sites could not be read.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    put:
      operationId: setUserSites
      summary: Set a user's sites
      tags:
      - Users
      description: 'Replace the user''s site assignments. `site_ids` is required — omitting it (or sending
        null) is 400; an empty array removes all site access. Blank entries are trimmed away and duplicates
        removed; unknown site ids are rejected with 400 (naming them). Applying this attempts to revoke
        the user''s sessions, but that revocation is best-effort: if it fails after the assignments commit,
        the error is logged and the request still returns 200 (the sessions may survive until refresh
        or expiry). The response echoes the cleaned, applied list. As elsewhere, an already-issued access
        token survives this revocation until it expires.'
      security: *id011
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetUserSitesRequest'
            example:
              site_ids:
              - site-london
              - site-newyork
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SetUserSitesResult'
              example:
                success: true
                user_id: u_01H8OP
                site_ids:
                - site-london
                - site-newyork
        '400':
          description: '`site_ids` is missing, a site id is unknown, or the user does not exist.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: Site assignment is unavailable on this appliance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/vault/credentials:
    post:
      operationId: createVaultCredential
      summary: Store a credential
      tags:
      - Vault
      description: 'Store a new credential (secrets sent in the body in plaintext). Requires `settings.write`.
        Returns HTTP 200 (not 201) with the masked credential. SHARP EDGES: `sql_server` (and any type
        lacking a validation branch) is rejected with a 400 despite being advertised by /types; a duplicate
        name is a 409. 423-locked in recovery mode.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CredentialInput'
            example:
              credential_type: cloudstack
              credential_name: prod-cs
              credential_data:
                api_host: cs.example.local
                api_key: REDACTED
                secret_key: REDACTED
              scope: global
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VaultCredentialResponse'
        '400':
          description: Malformed body, or validation failed (missing required field, bad scope, or an
            unsupported type such as sql_server).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VaultError'
        '409':
          description: A credential with that name already exists in the (type, scope, scope_id).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VaultError'
        '500':
          description: An encryption or database error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VaultError'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/vault/credentials/{id}:
    delete:
      operationId: deleteVaultCredential
      summary: Delete a credential
      tags:
      - Vault
      description: 'Hard-delete a credential (no in-use check). Requires `settings.write`. Site-gated
        under enforced multi-tenancy: a credential scoped to a site outside the caller''s reach answers
        404 with a bare `{"error":"not found"}` from the scope gate rather than the vault envelope. A
        tenant delete cascades the credentials scoped to the tenant''s sites itself; this is for one it
        did not (scoped elsewhere) or for removing a single credential without deleting the tenant. 423-locked
        in recovery mode.'
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VaultDeleteResponse'
        '400':
          description: The id is empty.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VaultError'
        '404':
          description: The credential was not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VaultError'
        '500':
          description: A database error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VaultError'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/vault/credentials/{id}/test:
    post:
      operationId: testStoredVaultCredential
      summary: Test a stored credential
      tags:
      - Vault
      description: Test connectivity for a stored credential (loaded + decrypted server-side; persists
        its test_status). Requires `settings.write`. No secret echoed. 423-locked in recovery mode.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      x-bodyless-request: true
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VaultTestResponse'
        '400':
          description: The id is empty.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VaultError'
        '404':
          description: The credential was not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VaultError'
        '500':
          description: A test/transport error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VaultError'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '423':
          description: The appliance is in control-plane recovery mode; writes are paused until reconciliation
            completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/vm-contexts:
    get:
      operationId: listVMContexts
      summary: List VM inventory
      tags:
      - VM contexts
      description: List VM inventory records with source health and group memberships. Requires `inventory.read`.
        Site-filtered in-handler under enforce mode (foreign rows silently dropped). Optional filters
        narrow the query.
      parameters:
      - name: site_id
        in: query
        required: false
        description: Filter by owning site (still re-filtered by the caller's own scope).
        schema:
          type: string
      - name: management_status
        in: query
        required: false
        description: Filter by management status; a comma-separated list is accepted.
        schema:
          type: string
      - name: platform
        in: query
        required: false
        description: Filter by source platform.
        schema:
          type: string
      - name: search
        in: query
        required: false
        description: Case-insensitive name/substring search.
        schema:
          type: string
      - name: folder_path
        in: query
        required: false
        description: Filter by VMware inventory folder path.
        schema:
          type: string
      - name: resource_pool
        in: query
        required: false
        description: Filter by VMware resource pool.
        schema:
          type: string
      - name: vcenter_host
        in: query
        required: false
        description: Filter by source vCenter host.
        schema:
          type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VMContextListResponse'
        '500':
          description: Failed to retrieve VM contexts. JSON `{error,timestamp}` (ErrorResponse-compatible).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The caller lacks the required permission or is outside its site scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
