{
  "openapi": "3.1.0",
  "info": {
    "title": "GroundIT — Platform Inbound & SSO",
    "version": "0.2.0",
    "license": {
      "name": "Proprietary — GroundIT/Sysmedac internal"
    },
    "description": "Surface 2 of the API contract round (api-docs/00 §2): the inbound `/platform/*` boundary the sysm-saas One platform calls to drive GroundIT's tenant lifecycle, membership/RBAC mirror, and entitlement cache (`xc.tenant_cache`, `xc.platform_events`, `admin.entitlements`, ADR 0009), plus the bridge-token SSO exchange that lands a One-portal user in GroundIT already signed in (`POST /api/sso/exchange`, ADR 0010 §b). See ../../08-surface-platform-and-sso.md and ../../00-api-overview-and-conventions.md.\n\n**0.2.0 (2026-08-13, ADR 0038 / mail leg 3, issue #836)** — `POST /platform/email-settings/test` now **accepts** a test send: `202` replaces the `501` refusal, which ADR 0038 made false by building the `MailClient` port, the per-tenant secret resolver and the jobs-tier dispatcher. `409 email_not_configured` is retained and is now decided by the `MAIL_PROVIDERS` registry (`describeMailConfig`), so its detail names the missing FIELD NAMES. `EmailSettings` and `EmailSettingsUpsertInput` gain an additive `encryption` (`STARTTLS | TLS | NONE`) alongside the retained, deprecated `smtpSecure`.\n"
  },
  "servers": [
    {
      "url": "/"
    }
  ],
  "security": [
    {
      "platformHmac": []
    }
  ],
  "tags": [
    {
      "name": "platform",
      "description": "The sysm-saas platform-inbound boundary — callable only by the platform, never product-app clients."
    },
    {
      "name": "tenant",
      "description": "Tenant/workspace lifecycle over xc.tenant_cache — provision, suspend, resume, cancel, purge, plan, sync, usage, subscription-status, impersonate."
    },
    {
      "name": "member",
      "description": "Workspace-member ↔ xc.identities mirror (web back-office personas, ADR 0010 §a)."
    },
    {
      "name": "rbac-mirror",
      "description": "GroundIT-authored role/permission/branch catalogue the platform reads to drive its plan/grant builder (XC-F04)."
    },
    {
      "name": "settings",
      "description": "Per-workspace BYOK settings (Razorpay payment · transactional email) the portal's product settings tabs read and write."
    },
    {
      "name": "health",
      "description": "The one unauthenticated liveness endpoint on this surface."
    },
    {
      "name": "sso",
      "description": "Bridge-token SSO exchange — mints a GroundIT web product session from the platform's Ed25519 token."
    }
  ],
  "x-reuse-anchors": {
    "parameters": {
      "tenant_uid_path": {
        "name": "tenantUid",
        "in": "path",
        "required": true,
        "description": "The platform-issued workspace identity (`xc.tenant_cache.tenant_id` PK, db 14 §2; ADR 0009 §c — \"the field is named `tenantUid`\"). Surface-2 exception to the \"no tenant in path\" rule (file header note 2) — there is no per-request tenant JWT on this HMAC-authenticated surface.\n",
        "schema": {
          "$ref": "#/components/schemas/Uuid"
        }
      },
      "tenant_uid_query": {
        "name": "tenantUid",
        "in": "query",
        "required": false,
        "description": "Same identity as the `tenantUid` path parameter (file header note 2) — required on every RBAC-mirror list/search op since these endpoints carry no tenant path segment of their own.\n\n**Either `tenantUid` or `workspaceUid` MUST be present** (neither → `422`); `tenantUid` wins when both are sent. Neither is marked `required: true` because OpenAPI cannot express \"exactly one of these two parameters\" — the same reason `MemberUpsertInput` expresses the pair as an `anyOf` on the body. This is the query-string half of the two-names rule that already governs the membership bodies (`08 §14.7`).\n",
        "schema": {
          "$ref": "#/components/schemas/Uuid"
        }
      },
      "workspace_uid_query": {
        "name": "workspaceUid",
        "in": "query",
        "required": false,
        "description": "The PORTAL's name for the same workspace (`tpa.workspaceUid`) — an accepted alias of `tenantUid`, not a second identity. Its own clients call these endpoints with it (`ProductClient.listUsers` / `listRoles` / `listBranches` all build `?workspaceUid=…`), so accepting only GroundIT's spelling made the control plane's real calls fail on a field name alone (`08 §14.7`).\n",
        "schema": {
          "$ref": "#/components/schemas/Uuid"
        }
      },
      "platform_timestamp": {
        "name": "X-Platform-Timestamp",
        "in": "header",
        "required": true,
        "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
        "schema": {
          "type": "string"
        }
      },
      "idempotency_key": {
        "$ref": "#/components/parameters/IdempotencyKey"
      },
      "page_size": {
        "$ref": "#/components/parameters/PageSize"
      },
      "page_after": {
        "$ref": "#/components/parameters/PageAfter"
      },
      "page_before": {
        "$ref": "#/components/parameters/PageBefore"
      },
      "path_id": {
        "$ref": "#/components/parameters/PathId"
      }
    },
    "responses": {
      "unauthorized": {
        "$ref": "#/components/responses/Unauthorized"
      },
      "not_found": {
        "$ref": "#/components/responses/NotFound"
      },
      "forbidden": {
        "$ref": "#/components/responses/Forbidden"
      },
      "gone": {
        "$ref": "#/components/responses/Gone"
      },
      "conflict": {
        "$ref": "#/components/responses/Conflict"
      },
      "unprocessable": {
        "$ref": "#/components/responses/UnprocessableEntity"
      },
      "too_many": {
        "$ref": "#/components/responses/TooManyRequests"
      }
    },
    "headers": {
      "idem_replayed": {
        "$ref": "#/components/headers/IdempotencyReplayed"
      },
      "location": {
        "$ref": "#/components/headers/Location"
      }
    }
  },
  "paths": {
    "/api/v1/internal/sms-relay/otp": {
      "post": {
        "operationId": "platform.sms_relay.otp",
        "summary": "Deliver a Keycloak-owned OTP through MSG91",
        "description": "Internal service-to-service route, before a tenant or employee session exists. SkipTenantEnforcement and no RBAC token deliberately; a constant-time SMS_RELAY_TOKEN bearer check authenticates the realm service. No platform HMAC or user token is accepted. Keycloak generates and verifies the OTP. Neither request bodies, provider URLs, nor provider errors are logged. Five attempts per destination per ten minutes, using a Redis HMAC key.\n",
        "security": [
          {
            "smsRelayBearer": []
          }
        ],
        "x-token": "platform.sms_relay.otp",
        "x-rls-scope": null,
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "destination",
                  "code",
                  "purpose",
                  "realm"
                ],
                "properties": {
                  "destination": {
                    "type": "string",
                    "pattern": "^\\+[1-9][0-9]{7,14}$"
                  },
                  "code": {
                    "type": "string",
                    "pattern": "^[0-9]{6}$",
                    "writeOnly": true
                  },
                  "purpose": {
                    "type": "string",
                    "enum": [
                      "LOGIN",
                      "PASSWORD_RESET"
                    ]
                  },
                  "realm": {
                    "type": "string",
                    "description": "Must match a configured GroundIT realm."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Provider accepted the SMS."
          },
          "401": {
            "description": "Missing or invalid service bearer or realm."
          },
          "422": {
            "description": "Invalid request body."
          },
          "429": {
            "description": "Destination quota exhausted."
          },
          "502": {
            "description": "Provider delivery failed."
          },
          "503": {
            "description": "Rate-limit storage unavailable; delivery refused."
          }
        }
      }
    },
    "/platform/health": {
      "get": {
        "operationId": "platform.health.check",
        "summary": "Liveness/readiness check for the /platform/* integration surface",
        "description": "The only unauthenticated endpoint on this surface (ADR 0009 §a) — returns product version and a DB connectivity check. Used by the portal's health monitors ahead of any tenant-scoped call.\n",
        "tags": [
          "platform",
          "health"
        ],
        "x-token": "platform.health.check",
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "security": [],
        "responses": {
          "200": {
            "description": "Service is live.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HealthStatus"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/api/v1/products": {
      "post": {
        "operationId": "platform.product.register",
        "summary": "One-time product registration — receive and hash the portal service token",
        "description": "ADR 0009 §f: ops registers the product on saas-portal (`POST /api/v1/products` there), then relays the one-time plaintext service token to the product via this endpoint. The product hashes it with bcrypt immediately and never stores the plaintext; re-registration with a new token rotates the hash (the current hash becomes `_PREV` so in-flight calls drain). Unlike the rest of this surface this route lives under the product's ordinary `/api/v1` prefix, matching the ADR's literal path.\n",
        "tags": [
          "platform",
          "product"
        ],
        "x-token": "platform.product.register",
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.product_registration"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "productCode",
                  "serviceToken"
                ],
                "properties": {
                  "productCode": {
                    "type": "string",
                    "maxLength": 64
                  },
                  "serviceToken": {
                    "type": "string",
                    "maxLength": 512
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Registration recorded; token hash stored.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "productCode",
                    "registeredAt"
                  ],
                  "properties": {
                    "productCode": {
                      "type": "string"
                    },
                    "registeredAt": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/tenants/provision": {
      "post": {
        "operationId": "platform.tenant.provision",
        "summary": "Provision a new GroundIT workspace for a platform tenant",
        "description": "Creates the `xc.tenant_cache` row for a newly-subscribed workspace (db 14 §2; ADR 0009 §a/§c) and appends `xc.platform_events` (`event_type='TENANT_PROVISION'`, db 14 §2) as the idempotency ledger entry. This is the root write every other per-tenant table's `tenant_id` soft-ref (`ref→xc.tenant_cache`) depends on existing.\n\n**It also instantiates the workspace's system RBAC catalogue** — the ~793 catalogued permission tokens, the 9 system roles and their default grants, per tenant (db 16 §3.2/§5.1.1) — in the SAME transaction as the cache row and the ledger append. A workspace whose catalogue was never instantiated is unusable: its members authenticate and every permission-gated product endpoint answers `403`. The catalogue's CONTENT is GroundIT's (projected from its own permission matrix); the platform neither supplies nor can influence it, and no request/response field changes. Behaviour a caller can observe: provisioning is measurably slower (~1–2 s), and a retried `Idempotency-Key` replays the CACHED `201` — the original body, deep-equal though JSON key order may differ, with `Idempotency-Replayed: true` — without re-applying anything (header note 8).\n",
        "tags": [
          "platform",
          "tenant"
        ],
        "x-token": "platform.tenant.provision",
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.tenant_cache",
          "xc.platform_events",
          "admin.permissions",
          "admin.role_catalog",
          "admin.roles",
          "admin.role_permissions"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProvisionInput"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Workspace provisioned.",
            "headers": {
              "Location": {
                "$ref": "#/components/headers/Location"
              },
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TenantCacheSummary"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/tenants/{tenantUid}/suspend": {
      "post": {
        "operationId": "platform.tenant.suspend",
        "summary": "Suspend a workspace",
        "description": "`xc.tenant_cache.status → 'SUSPENDED'` (db 14 §2); the product-app entitlement middleware then blocks login (`403`, ADR 0009 §e). Appends `xc.platform_events` `event_type='TENANT_SUSPEND'`.\n",
        "tags": [
          "platform",
          "tenant"
        ],
        "x-token": "platform.tenant.suspend",
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.tenant_cache",
          "xc.platform_events"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "tenantUid",
            "in": "path",
            "required": true,
            "description": "The platform-issued workspace identity (`xc.tenant_cache.tenant_id` PK, db 14 §2; ADR 0009 §c — \"the field is named `tenantUid`\"). Surface-2 exception to the \"no tenant in path\" rule (file header note 2) — there is no per-request tenant JWT on this HMAC-authenticated surface.\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TenantLifecycleReasonInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Workspace suspended.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TenantCacheSummary"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/tenants/{tenantUid}/resume": {
      "post": {
        "operationId": "platform.tenant.resume",
        "summary": "Resume a suspended or past-due workspace",
        "description": "`xc.tenant_cache.status → 'ACTIVE'` (db 14 §2). Appends `xc.platform_events` `event_type='TENANT_RESUME'`. `409 STATE_TRANSITION_INVALID` when the current status isn't resumable (e.g. already `CANCELLED`).\n",
        "tags": [
          "platform",
          "tenant"
        ],
        "x-token": "platform.tenant.resume",
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.tenant_cache",
          "xc.platform_events"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "tenantUid",
            "in": "path",
            "required": true,
            "description": "The platform-issued workspace identity (`xc.tenant_cache.tenant_id` PK, db 14 §2; ADR 0009 §c — \"the field is named `tenantUid`\"). Surface-2 exception to the \"no tenant in path\" rule (file header note 2) — there is no per-request tenant JWT on this HMAC-authenticated surface.\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TenantLifecycleReasonInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Workspace resumed.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TenantCacheSummary"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/tenants/{tenantUid}/cancel": {
      "post": {
        "operationId": "platform.tenant.cancel",
        "summary": "Cancel a workspace subscription",
        "description": "`xc.tenant_cache.status → 'CANCELLED'` (db 14 §2); the product-app entitlement middleware then returns `410` (login and all product calls blocked, ADR 0009 §e). Appends `xc.platform_events` `event_type='TENANT_CANCEL'`. Distinct from `platform.tenant.purge` — cancellation retains the row (and statutory-retained data) for the hard-purge window; purge is the destructive follow-up call.\n",
        "tags": [
          "platform",
          "tenant"
        ],
        "x-token": "platform.tenant.cancel",
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.tenant_cache",
          "xc.platform_events"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "tenantUid",
            "in": "path",
            "required": true,
            "description": "The platform-issued workspace identity (`xc.tenant_cache.tenant_id` PK, db 14 §2; ADR 0009 §c — \"the field is named `tenantUid`\"). Surface-2 exception to the \"no tenant in path\" rule (file header note 2) — there is no per-request tenant JWT on this HMAC-authenticated surface.\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TenantLifecycleReasonInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Workspace cancelled.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TenantCacheSummary"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/tenants/{tenantUid}": {
      "delete": {
        "operationId": "platform.tenant.purge",
        "summary": "Hard-purge a cancelled workspace",
        "description": "The destructive follow-up to `platform.tenant.cancel` (ADR 0009 §a \"`DELETE tenants/:uid` (hard purge)\"). Deletes **every row in every tenant-owned table** — the `admin.*` RBAC catalogue, `xc.identities`/`xc.sessions`, and all operational HRMS data across the module schemas — with the `xc.tenant_cache` row **last**, in one transaction. The table set is discovered from the database catalog rather than a maintained list, so a table added by a future module is purged from the day it exists. If any row would survive, the whole transaction aborts and this returns `5xx`: a `200` here means the database erasure completed.\n\n**Always `200`, never `404`** — purging an unknown or already-purged workspace succeeds, so a purge whose response was lost in flight can be retried safely. A replayed `Idempotency-Key` returns the original `purgedAt` verbatim (the purge's own cached response deliberately outlives its own walk).\n\n**The object-storage bytes are erased ASYNCHRONOUSLY — a `200` does not mean they are gone yet.** A `200` means: the database erasure completed synchronously, AND the tenant's object-storage erasure is durably enqueued. Byte deletion cannot join the purge transaction (it could not roll back with it, and the object count is unbounded against the 60 s budget), so what joins the transaction is the DECISION to sweep: an `xc.tenant_objects.purge_requested` outbox event plus a marker on the tenant's directory row, both committed with the purge. A rolled-back purge enqueues nothing; a committed purge cannot lose the erasure. A jobs-tier consumer then sweeps the bucket prefix `<tenantUid>/` in bounded, resumable batches and clears the marker only once the prefix is provably empty — that is when the tenant's erasure is complete. Operators confirm it with `SELECT tenant_id, purged_at FROM xc.tenant_directory WHERE purged_at IS NOT NULL` (empty = every purge finished); diagnosis and manual re-run are in ops-docs/01 §6.5. Closes api-docs/05 `GAP-10`.\n\n**One documented exception remains.** The append-only `audit.*` plane is RETAINED — the audit/access trail is not erased by a tenant purge; if an erasure attestation to a customer must cover audit rows, it cannot today (api-docs/05 `GAP-09`). `xc.platform_events` is also retained, by design: it is the seam's exactly-once gate, and the appended `event_type='TENANT_PURGE'` row (carrying the optional `reason`/`confirmedAt` you send) is the durable record of what was purged and when.\n\nIrreversible. The typed-name confirmation and owner-role check stay on the portal side; this endpoint executes. See `api-docs/08 §12`.\n",
        "tags": [
          "platform",
          "tenant"
        ],
        "x-token": "platform.tenant.purge",
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.tenant_cache",
          "xc.platform_events",
          "xc.identities",
          "xc.sessions",
          "admin.roles",
          "admin.entitlements",
          "people.employees"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "tenantUid",
            "in": "path",
            "required": true,
            "description": "The platform-issued workspace identity (`xc.tenant_cache.tenant_id` PK, db 14 §2; ADR 0009 §c — \"the field is named `tenantUid`\"). Surface-2 exception to the \"no tenant in path\" rule (file header note 2) — there is no per-request tenant JWT on this HMAC-authenticated surface.\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": false,
          "description": "Optional. Recorded verbatim in the `xc.platform_events` ledger row so the operator's stated justification survives the purge; neither field gates the operation.\n",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PurgeInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Workspace purged — every tenant-owned database row erased synchronously, AND the tenant's object-storage erasure durably enqueued (the bytes are swept asynchronously by the jobs tier; see the description, and the audit-plane retention). Also returned when the workspace was already unknown here.\n",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PurgeResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/tenants/{tenantUid}/plan": {
      "patch": {
        "operationId": "platform.tenant.change_plan",
        "summary": "Change a workspace's subscribed plan, limits, and feature flags",
        "description": "Partial update (merge) of `xc.tenant_cache.plan_code`/`limits`/`features` (db 14 §2). Appends `xc.platform_events` `event_type='PLAN_CHANGE'`. No `If-Match` — `xc.tenant_cache` carries no `version` column (file header note 7); this surface is the row's single authoritative writer.\n",
        "tags": [
          "platform",
          "tenant"
        ],
        "x-token": "platform.tenant.change_plan",
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.tenant_cache",
          "xc.platform_events"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "tenantUid",
            "in": "path",
            "required": true,
            "description": "The platform-issued workspace identity (`xc.tenant_cache.tenant_id` PK, db 14 §2; ADR 0009 §c — \"the field is named `tenantUid`\"). Surface-2 exception to the \"no tenant in path\" rule (file header note 2) — there is no per-request tenant JWT on this HMAC-authenticated surface.\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlanChangeInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Plan/limits/features updated.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TenantCacheSummary"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/tenants/{tenantUid}/branding": {
      "patch": {
        "operationId": "platform.tenant.update_branding",
        "summary": "Push a workspace's branding (name / logo / brand colour)",
        "description": "The portal's STANDALONE branding rail — `ProductClient.updateBranding` → `services/customer/syncWorkspaceBranding.ts`, fired when an owner edits the logo or brand colour between a `provision` and the next reconcile. Its body is `{ logoUrl, primaryColor }` and it sends BOTH on every edit (an explicit `null` where the workspace has none); `name` is accepted as a third key because the same write-through owns it, though the portal carries the name on `provision`/`sync` rather than here. Appends `xc.platform_events` `event_type='BRANDING_UPDATE'` (migration 0055 — its own value, so the ledger never claims a full-state reconcile that did not happen).\n\n**WRITES THROUGH TO `admin.tenant_config`, NOT TO THE CACHE.** `xc.tenant_cache` deliberately has no `name`/`logo_url`/`primary_color` column: these three facts have exactly one reader — the `GET /api/v1/auth/session` projection — and it sources them from `admin.tenant_config` under `branding.company_name` / `branding.primary_color` / `branding.logo_url` (db-docs/02 §2.4, migration 0052). This op reuses the SAME write-through `provision`/`sync` use, so branding has one storage location and one semantics, not a second copy that drifts.\n\n**ABSENT ≠ CLEARED.** A field the caller omits leaves the stored value untouched; only an explicit `null` clears it (stored as `{\"value\": null}`, which the reader renders as unset). That is what stops a partial PATCH from blanking a tenant's theme. An EMPTY body is therefore a `422`, not a successful no-op — a request that names no field asserted nothing, and must not burn an idempotency key.\n\n`404` when the workspace is unknown to GroundIT: `admin.tenant_config` carries no FK to `xc.tenant_cache`, so without the guard a typo'd `tenantUid` would quietly stage branding for a workspace that might later be provisioned and inherit a stranger's theme.\n",
        "tags": [
          "platform",
          "tenant"
        ],
        "x-token": "platform.tenant.update_branding",
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "admin.tenant_config",
          "xc.tenant_cache",
          "xc.platform_events"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "tenantUid",
            "in": "path",
            "required": true,
            "description": "The platform-issued workspace identity (`xc.tenant_cache.tenant_id` PK, db 14 §2; ADR 0009 §c — \"the field is named `tenantUid`\"). Surface-2 exception to the \"no tenant in path\" rule (file header note 2) — there is no per-request tenant JWT on this HMAC-authenticated surface.\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BrandingUpdateInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Branding applied. The body reports PERSISTED state, so an omitted field visibly kept its previous value.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BrandingUpdateResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/tenants/{tenantUid}/sync": {
      "post": {
        "operationId": "platform.tenant.sync",
        "summary": "Full-state reconciliation/recovery sync of a workspace",
        "description": "The platform resends its full view of one workspace's state — status, plan, limits, features, subscription window — and GroundIT overwrites `xc.tenant_cache` to match (db 14 §2, ADR 0009 §a \"`…/sync` (full-state reconciliation/recovery)\"). Appends `xc.platform_events` `event_type='SYNC'`. The drift-recovery path when a prior inbound call was lost or GroundIT's cache is suspected stale.\n\nSync also converges the workspace's system RBAC catalogue (db 16 §5.1.1): the natural-key bulk upsert repairs an absent catalogue and carries newly catalogued security permissions to an existing tenant.\n",
        "tags": [
          "platform",
          "tenant"
        ],
        "x-token": "platform.tenant.sync",
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.tenant_cache",
          "xc.platform_events",
          "admin.permissions",
          "admin.role_catalog",
          "admin.roles",
          "admin.role_permissions"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "tenantUid",
            "in": "path",
            "required": true,
            "description": "The platform-issued workspace identity (`xc.tenant_cache.tenant_id` PK, db 14 §2; ADR 0009 §c — \"the field is named `tenantUid`\"). Surface-2 exception to the \"no tenant in path\" rule (file header note 2) — there is no per-request tenant JWT on this HMAC-authenticated surface.\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SyncInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reconciled workspace state.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TenantCacheSummary"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/tenants/{tenantUid}/usage": {
      "get": {
        "operationId": "platform.tenant.get_usage",
        "summary": "Report current usage against a workspace's plan limits",
        "description": "GroundIT **reports** usage; it does not decide limits (ADR 0009 §e — \"GroundIT *reports* usage via `…/usage`; it does not *decide* limits\"). The plan builder's numeric-limit gauges are sourced from this call.\n\nThe per-resource counts are **live reads of the product tables** (`people.employees`, `xc.identities`, `org.legal_entities`, `org.work_locations`) in ONE tenant-scoped FORCE-RLS transaction — not a maintained counter that can drift from the tables it describes — over exactly the predicates the create paths enforce against. `storageMb` remains derived from `xc.tenant_cache.storage_bytes_used`, and `limits` from `xc.tenant_cache.limits`.\n\n`usage` keys pair 1:1 with the `max…` keys in `limits` (`maxEmployees` ↔ `employees`). Counts are a point-in-time snapshot (`asOf`); the caller should not cache them, as the product's own enforcement re-counts on every create.\n",
        "tags": [
          "platform",
          "tenant"
        ],
        "x-token": "platform.tenant.get_usage",
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.tenant_cache",
          "people.employees",
          "xc.identities",
          "org.legal_entities",
          "org.work_locations",
          "xc.sessions"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "tenantUid",
            "in": "path",
            "required": true,
            "description": "The platform-issued workspace identity (`xc.tenant_cache.tenant_id` PK, db 14 §2; ADR 0009 §c — \"the field is named `tenantUid`\"). Surface-2 exception to the \"no tenant in path\" rule (file header note 2) — there is no per-request tenant JWT on this HMAC-authenticated surface.\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Current usage snapshot.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UsageReport"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/tenants/{tenantUid}/subscription-status": {
      "post": {
        "operationId": "platform.tenant.update_subscription_status",
        "summary": "Push a subscription-status signal for a workspace",
        "description": "The platform's **V1.5** entitlement contract (ADR 0009 \"Version note\"): pushes `status`/ `masterStatus`/subscription-window changes independent of the coarser lifecycle actions above. Base **V1** platforms return `410` on suspend/cancel only and never call this op; GroundIT builds to V1.5 and degrades gracefully where the signal is absent. Appends `xc.platform_events` `event_type='SUBSCRIPTION_STATUS'`.\n",
        "tags": [
          "platform",
          "tenant"
        ],
        "x-token": "platform.tenant.update_subscription_status",
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.tenant_cache",
          "xc.platform_events"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "tenantUid",
            "in": "path",
            "required": true,
            "description": "The platform-issued workspace identity (`xc.tenant_cache.tenant_id` PK, db 14 §2; ADR 0009 §c — \"the field is named `tenantUid`\"). Surface-2 exception to the \"no tenant in path\" rule (file header note 2) — there is no per-request tenant JWT on this HMAC-authenticated surface.\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SubscriptionStatusInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Subscription status applied.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TenantCacheSummary"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/tenants/{tenantUid}/impersonate": {
      "post": {
        "operationId": "platform.tenant.start_impersonation",
        "summary": "Start an impersonation session into a workspace",
        "description": "Platform super-admin / delegated-admin impersonation (ADR 0009 §a \"`…/impersonate`\"; db 14 §1 `xc.sessions.impersonated_by`). Mints a session with `impersonated_by` set to the acting platform principal — surfaced in-product as the impersonation banner and attributed in the append-only audit trail (XC-F06, XC-F14). Appends `xc.platform_events` `event_type='IMPERSONATE'`.\n",
        "tags": [
          "platform",
          "tenant"
        ],
        "x-token": "platform.tenant.start_impersonation",
        "x-realizes-features": [
          "XC-F02",
          "XC-F14"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.sessions",
          "xc.platform_events"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "tenantUid",
            "in": "path",
            "required": true,
            "description": "The platform-issued workspace identity (`xc.tenant_cache.tenant_id` PK, db 14 §2; ADR 0009 §c — \"the field is named `tenantUid`\"). Surface-2 exception to the \"no tenant in path\" rule (file header note 2) — there is no per-request tenant JWT on this HMAC-authenticated surface.\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ImpersonateInput"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Impersonation session started.",
            "headers": {
              "Location": {
                "$ref": "#/components/headers/Location"
              },
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ImpersonationSession"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/tenants/{tenantUid}/impersonate/{tokenId}/revoke": {
      "post": {
        "operationId": "platform.tenant.revoke_impersonation",
        "summary": "Revoke an active impersonation session",
        "description": "`xc.sessions.status → 'REVOKED'` (db 14 §1) for the named impersonation session. Appends `xc.platform_events` `event_type='IMPERSONATE'` recording the revocation.\n",
        "tags": [
          "platform",
          "tenant"
        ],
        "x-token": "platform.tenant.revoke_impersonation",
        "x-realizes-features": [
          "XC-F02",
          "XC-F14"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.sessions",
          "xc.platform_events"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "tenantUid",
            "in": "path",
            "required": true,
            "description": "The platform-issued workspace identity (`xc.tenant_cache.tenant_id` PK, db 14 §2; ADR 0009 §c — \"the field is named `tenantUid`\"). Surface-2 exception to the \"no tenant in path\" rule (file header note 2) — there is no per-request tenant JWT on this HMAC-authenticated surface.\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "tokenId",
            "in": "path",
            "required": true,
            "description": "The impersonation session id returned by `platform.tenant.start_impersonation`.",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "Impersonation session revoked.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ImpersonationRevokeResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/workspace-members/upsert": {
      "post": {
        "operationId": "platform.member.upsert",
        "summary": "Create or update a workspace-member's GroundIT identity binding",
        "description": "Mirrors a platform workspace-member into `xc.identities` (db 14 §1) — creates or updates the row keyed by `(tenantUid, platformMemberId)` (unique per db 14 §1), carrying the member's contact fields and the role grants the platform is issuing over GroundIT's role catalogue (features-docs XC-F04: \"GroundIT defines *what roles mean*, platform decides *who has them*\"). The principal is created as `principal_type='WORKSPACE_MEMBER'` with NO employee subject: a member need not be a GroundIT employee at all (an external accountant, consultant or auditor holding a Finance seat draws no pay from the tenant), and this payload deliberately carries no way to assert one. When the member IS also an employee (e.g. an HR admin with payslips), the identity-linking seam binds this principal to `people.employees.id` server-side (ADR 0010 §e), through `people.employee_subjects` — never from this call. Appends `xc.platform_events` `event_type='MEMBER_UPSERT'`.\n",
        "tags": [
          "platform",
          "member"
        ],
        "x-token": "platform.member.upsert",
        "x-realizes-features": [
          "XC-F02",
          "XC-F04"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.identities",
          "xc.platform_events"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MemberUpsertInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Identity binding created or updated.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MemberUpsertResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "`maxUsers` reached — this push would mint a NEW login-capable seat the plan does not cover. `code` = PLAN_LIMIT_EXCEEDED (problem+json), `detail` names the resource, the current count and the cap. The DELIBERATE exception to this file's \"no entitlement codes on /platform/*\" rule (header note 4): a member upsert is the only inbound op that CREATES a plan-metered resource rather than describing entitlement state, so gating it is not circular. Gated on the seat DELTA, never on the call — a repeat push for a member who already holds a seat, or a push with `status: DEACTIVATED`, always succeeds, so an at-limit workspace can still be administered.\n",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/workspace-members": {
      "delete": {
        "operationId": "platform.member.remove",
        "summary": "Remove a workspace-member's GroundIT identity binding (the portal's verb)",
        "description": "**The canonical removal operation** — this is the verb + path the portal's own client issues (`ProductClient.deleteWorkspaceMember`: `DELETE /platform/workspace-members` with a `{ workspaceUid, email }` body). Identical behaviour to the `POST …/delete` alias below: one handler, one `event_type`, so a mixed fleet cannot produce two different outcomes.\n`xc.identities.status → 'DEACTIVATED'` (db 14 §1, soft — never hard-deleted, soft-ref orphan tolerance) and every ACTIVE `admin.member_grants` row for the member is revoked. A no-op-but-200 when the member is already deactivated or was never mirrored (idempotent by design; no `404`). Appends `xc.platform_events` `event_type='MEMBER_DELETE'`.\nThe body carries the addressing because the platform identifies a member by natural key — an email, or GroundIT's opaque `platformMemberId` — never by a GroundIT-side id it does not hold at call time (ADR 0009 §a). That is also why there is no `{id}` path segment.\n",
        "tags": [
          "platform",
          "member"
        ],
        "x-token": "platform.member.remove",
        "x-realizes-features": [
          "XC-F02",
          "XC-F04"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.identities",
          "admin.member_grants",
          "xc.platform_events"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MemberDeleteInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Identity binding deactivated (or already was).",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MemberDeleteResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/workspace-members/delete": {
      "post": {
        "operationId": "platform.member.delete",
        "summary": "Remove a workspace-member's GroundIT identity binding (alias of the DELETE verb)",
        "description": "**Alias, retained for compatibility.** The original spelling of the removal op, chosen per ADR 0009 §a before it was confirmed that the portal issues `DELETE /platform/workspace-members`. Both routes share one handler, one body schema and one `event_type='MEMBER_DELETE'`; prefer the `DELETE` verb.\n`xc.identities.status → 'DEACTIVATED'` (db 14 §1, soft — never hard-deleted, soft-ref orphan tolerance). A no-op-but-200 when the member is already deactivated or was never mirrored (idempotent by design; no `404`). Appends `xc.platform_events` `event_type='MEMBER_DELETE'`.\n",
        "tags": [
          "platform",
          "member"
        ],
        "x-token": "platform.member.delete",
        "x-realizes-features": [
          "XC-F02",
          "XC-F04"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.identities",
          "xc.platform_events"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MemberDeleteInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Identity binding deactivated (or already was).",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MemberDeleteResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/role-catalog": {
      "get": {
        "operationId": "platform.role_catalog.list",
        "summary": "List GroundIT's published role-definition catalogue for a workspace",
        "description": "Exposes `admin.role_catalog` (db 02 §2.1) — the stable role-identity set the platform issues grants against (features-docs XC-F04: \"`/platform/role-catalog`\"). Sort whitelist: `catalogKey`, `-catalogKey` (default `catalogKey`).\n",
        "tags": [
          "platform",
          "rbac-mirror"
        ],
        "x-token": "platform.role_catalog.list",
        "x-realizes-features": [
          "XC-F04"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "admin.role_catalog"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "tenantUid",
            "in": "query",
            "required": false,
            "description": "Same identity as the `tenantUid` path parameter (file header note 2) — required on every RBAC-mirror list/search op since these endpoints carry no tenant path segment of their own.\n\n**Either `tenantUid` or `workspaceUid` MUST be present** (neither → `422`); `tenantUid` wins when both are sent. Neither is marked `required: true` because OpenAPI cannot express \"exactly one of these two parameters\" — the same reason `MemberUpsertInput` expresses the pair as an `anyOf` on the body. This is the query-string half of the two-names rule that already governs the membership bodies (`08 §14.7`).\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "workspaceUid",
            "in": "query",
            "required": false,
            "description": "The PORTAL's name for the same workspace (`tpa.workspaceUid`) — an accepted alias of `tenantUid`, not a second identity. Its own clients call these endpoints with it (`ProductClient.listUsers` / `listRoles` / `listBranches` all build `?workspaceUid=…`), so accepting only GroundIT's spelling made the control plane's real calls fail on a field name alone (`08 §14.7`).\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/RoleCatalogStatus"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `catalogKey`, `-catalogKey`. Default `catalogKey`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of role-catalog entries.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RoleCatalogPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/roles": {
      "get": {
        "operationId": "platform.role.list",
        "summary": "List a workspace's tenant-authored role instances",
        "description": "Exposes `admin.roles` (db 02 §2.1) — the role instances the platform maps principals to (features-docs XC-F04: \"`/platform/roles`, which maps principals to roles\"). Sort whitelist: `roleKey`, `-roleKey` (default `roleKey`).\n",
        "tags": [
          "platform",
          "rbac-mirror"
        ],
        "x-token": "platform.role.list",
        "x-realizes-features": [
          "XC-F04"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "admin.roles"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "tenantUid",
            "in": "query",
            "required": false,
            "description": "Same identity as the `tenantUid` path parameter (file header note 2) — required on every RBAC-mirror list/search op since these endpoints carry no tenant path segment of their own.\n\n**Either `tenantUid` or `workspaceUid` MUST be present** (neither → `422`); `tenantUid` wins when both are sent. Neither is marked `required: true` because OpenAPI cannot express \"exactly one of these two parameters\" — the same reason `MemberUpsertInput` expresses the pair as an `anyOf` on the body. This is the query-string half of the two-names rule that already governs the membership bodies (`08 §14.7`).\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "workspaceUid",
            "in": "query",
            "required": false,
            "description": "The PORTAL's name for the same workspace (`tpa.workspaceUid`) — an accepted alias of `tenantUid`, not a second identity. Its own clients call these endpoints with it (`ProductClient.listUsers` / `listRoles` / `listBranches` all build `?workspaceUid=…`), so accepting only GroundIT's spelling made the control plane's real calls fail on a field name alone (`08 §14.7`).\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "scopeType",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/RoleScopeType"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/RoleStatus"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `roleKey`, `-roleKey`. Default `roleKey`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of role instances.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RolePage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/roles/{id}/permissions": {
      "get": {
        "operationId": "platform.role.list_permissions",
        "summary": "List the permission tokens granted to a role",
        "description": "Exposes the `admin.role_permissions` M:N join resolved to `admin.permissions` (db 02 §2.1) — `/platform/roles/:id/permissions` (ADR 0009 §a). Sort whitelist: `permissionKey`, `-permissionKey` (default `permissionKey`).\n",
        "tags": [
          "platform",
          "rbac-mirror"
        ],
        "x-token": "platform.role.list_permissions",
        "x-realizes-features": [
          "XC-F04"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "admin.role_permissions",
          "admin.permissions"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "name": "tenantUid",
            "in": "query",
            "required": false,
            "description": "Same identity as the `tenantUid` path parameter (file header note 2) — required on every RBAC-mirror list/search op since these endpoints carry no tenant path segment of their own.\n\n**Either `tenantUid` or `workspaceUid` MUST be present** (neither → `422`); `tenantUid` wins when both are sent. Neither is marked `required: true` because OpenAPI cannot express \"exactly one of these two parameters\" — the same reason `MemberUpsertInput` expresses the pair as an `anyOf` on the body. This is the query-string half of the two-names rule that already governs the membership bodies (`08 §14.7`).\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "workspaceUid",
            "in": "query",
            "required": false,
            "description": "The PORTAL's name for the same workspace (`tpa.workspaceUid`) — an accepted alias of `tenantUid`, not a second identity. Its own clients call these endpoints with it (`ProductClient.listUsers` / `listRoles` / `listBranches` all build `?workspaceUid=…`), so accepting only GroundIT's spelling made the control plane's real calls fail on a field name alone (`08 §14.7`).\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `permissionKey`, `-permissionKey`. Default `permissionKey`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of permission tokens granted to this role.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PermissionPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/branches": {
      "get": {
        "operationId": "platform.branch.list",
        "summary": "List a workspace's branch hierarchy",
        "description": "Exposes `admin.branches` (db 02 §2.1) — the branch dimension of RBAC scope (features-docs XC-F04: \"`/platform/branches`\"). Sort whitelist: `branchCode`, `-branchCode` (default `branchCode`).\n",
        "tags": [
          "platform",
          "rbac-mirror"
        ],
        "x-token": "platform.branch.list",
        "x-realizes-features": [
          "XC-F04"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "admin.branches"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "tenantUid",
            "in": "query",
            "required": false,
            "description": "Same identity as the `tenantUid` path parameter (file header note 2) — required on every RBAC-mirror list/search op since these endpoints carry no tenant path segment of their own.\n\n**Either `tenantUid` or `workspaceUid` MUST be present** (neither → `422`); `tenantUid` wins when both are sent. Neither is marked `required: true` because OpenAPI cannot express \"exactly one of these two parameters\" — the same reason `MemberUpsertInput` expresses the pair as an `anyOf` on the body. This is the query-string half of the two-names rule that already governs the membership bodies (`08 §14.7`).\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "workspaceUid",
            "in": "query",
            "required": false,
            "description": "The PORTAL's name for the same workspace (`tpa.workspaceUid`) — an accepted alias of `tenantUid`, not a second identity. Its own clients call these endpoints with it (`ProductClient.listUsers` / `listRoles` / `listBranches` all build `?workspaceUid=…`), so accepting only GroundIT's spelling made the control plane's real calls fail on a field name alone (`08 §14.7`).\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "type",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/BranchType"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/BranchStatus"
            }
          },
          {
            "name": "parentBranchId",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `branchCode`, `-branchCode`. Default `branchCode`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of branches.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BranchPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/users": {
      "get": {
        "operationId": "platform.user.list",
        "summary": "List a workspace's principals (paginated)",
        "description": "The listing behind the portal's owner **\"Open as User\"** chooser, which calls exactly this path (`ProductClient.listUsers`: `GET /platform/users?workspaceUid=…`). GroundIT previously published only the `search` sibling, so the chooser had nothing to read (`08 §14.6`).\n\n**CURSOR-PAGED FROM ITS FIRST BYTE.** This is a NEW operation, not a relaxation of an old one: no unpaginated `/platform/users` was ever served or declared here, so there is no unbounded array to stay compatible with. It answers the same `{data, page}` envelope as every other list on this API (`_shared.yaml` `CursorPage`) — opaque keyset cursors, never offsets (ADR 0015).\n\n**SORT WHITELIST: `identityId`, `-identityId` (default `identityId`) — one key, deliberately, and it IS creation order.** `xc.identities.id` is an application-generated UUIDv7 (db-docs/00 §3), so ascending id is ascending creation time, while also being NOT NULL, unique (no tiebreaker needed) and lossless in a cursor. Both alternatives are wrong here: `created_at` is `timestamptz` (microseconds) but reaches the wire as a millisecond instant, so a cursor minted from it is TRUNCATED and the next page RE-EMITS the boundary row; and `email` is nullable, so `(email, id) > (…)` evaluates to NULL for every address-less principal and silently drops those rows from the walk. `createdAt` is reported on every row regardless — it is display data, not the keyset. Cursors are bound to the sort that minted them (file header note 9).\n\nUnlike `platform.user.search` this op takes no term and is **not restricted to `EMPLOYEE`** — the chooser needs every principal that can hold a session — so each row states its `principalType` and the caller narrows with the `principalType`/`status` filters. `displayName`/`employeeNo` remain null until the People module's projection lands (db-docs/00 §3), exactly as on the search shape.\n",
        "tags": [
          "platform",
          "rbac-mirror"
        ],
        "x-token": "platform.user.list",
        "x-realizes-features": [
          "XC-F02",
          "XC-F04"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.identities"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "tenantUid",
            "in": "query",
            "required": false,
            "description": "Same identity as the `tenantUid` path parameter (file header note 2) — required on every RBAC-mirror list/search op since these endpoints carry no tenant path segment of their own.\n\n**Either `tenantUid` or `workspaceUid` MUST be present** (neither → `422`); `tenantUid` wins when both are sent. Neither is marked `required: true` because OpenAPI cannot express \"exactly one of these two parameters\" — the same reason `MemberUpsertInput` expresses the pair as an `anyOf` on the body. This is the query-string half of the two-names rule that already governs the membership bodies (`08 §14.7`).\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "workspaceUid",
            "in": "query",
            "required": false,
            "description": "The PORTAL's name for the same workspace (`tpa.workspaceUid`) — an accepted alias of `tenantUid`, not a second identity. Its own clients call these endpoints with it (`ProductClient.listUsers` / `listRoles` / `listBranches` all build `?workspaceUid=…`), so accepting only GroundIT's spelling made the control plane's real calls fail on a field name alone (`08 §14.7`).\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "principalType",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/PrincipalType"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/IdentityStatus"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `identityId`, `-identityId` (creation order — the id is a uuidv7). Default `identityId`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of the workspace's principals.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UserListPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/users/search": {
      "get": {
        "operationId": "platform.user.search",
        "summary": "Search GroundIT identities/employees for workspace-member linking",
        "description": "Supports the platform's workspace-member linking flow (ADR 0009 §a \"`users/search`\") — searches `xc.identities` (db 14 §1) and resolves the linked `people.employees` projection where `principal_type='EMPLOYEE'`, without a cross-schema join (db-docs/00 §3/§12; this file declares its own read-model projection over `people.employees`, never the owning module's write model). Sort whitelist: `displayName`, `-displayName` (default `displayName`).\n",
        "tags": [
          "platform",
          "rbac-mirror"
        ],
        "x-token": "platform.user.search",
        "x-realizes-features": [
          "XC-F02",
          "XC-F04"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.identities",
          "people.employees"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "tenantUid",
            "in": "query",
            "required": false,
            "description": "Same identity as the `tenantUid` path parameter (file header note 2) — required on every RBAC-mirror list/search op since these endpoints carry no tenant path segment of their own.\n\n**Either `tenantUid` or `workspaceUid` MUST be present** (neither → `422`); `tenantUid` wins when both are sent. Neither is marked `required: true` because OpenAPI cannot express \"exactly one of these two parameters\" — the same reason `MemberUpsertInput` expresses the pair as an `anyOf` on the body. This is the query-string half of the two-names rule that already governs the membership bodies (`08 §14.7`).\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "workspaceUid",
            "in": "query",
            "required": false,
            "description": "The PORTAL's name for the same workspace (`tpa.workspaceUid`) — an accepted alias of `tenantUid`, not a second identity. Its own clients call these endpoints with it (`ProductClient.listUsers` / `listRoles` / `listBranches` all build `?workspaceUid=…`), so accepting only GroundIT's spelling made the control plane's real calls fail on a field name alone (`08 §14.7`).\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "Search term over email/phone/display name.",
            "schema": {
              "type": "string",
              "minLength": 1
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/IdentityStatus"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `displayName`, `-displayName`. Default `displayName`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of matching identities.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UserSearchPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/api/sso/exchange": {
      "post": {
        "operationId": "platform.sso.exchange",
        "summary": "Exchange a platform bridge-token for a GroundIT product session (web SSO)",
        "description": "Verifies the One-portal's 60-second single-use Ed25519 JWT and mints a GroundIT web product session (ADR 0009 §d; ADR 0010 §b; 05-integration-architecture.md §2.3). Verification order: signature (EdDSA against the platform's public key) → expiry (`exp`, ±5s skew) → audience → single-use `jti` (insert into a consumed-token table; a replayed `jti` → `409`) → tenant exists in `xc.tenant_cache` → member match (resolves/binds an `xc.identities` row) → mints a session (`xc.sessions`, `auth_method='BRIDGE_TOKEN'`, db 14 §1) and sets the product session cookie. Anonymous endpoint — the caller presents no bearer session; the signed token in the body IS the credential, so no Idempotency-Key floor applies (the single-use `jti` is this operation's own replay guard).\n\n**Tenant-status gate (ADR 0009 §e; saas-portal `docs/05 §5` login state machine).** The exchange stays mounted **ahead of** the request-time entitlement middleware so a `PAST_DUE` workspace can still land its users (reads allowed, writes blocked downstream — ADR 0009 \"Alternatives considered\"). That carve-out is for `PAST_DUE` only: a workspace whose `status` is `SUSPENDED` (or whose `masterStatus` is `SUSPENDED`) → `403 TENANT_SUSPENDED`, and `CANCELLED`/purged/absent → `410 TENANT_CANCELLED`. No session is minted and the single-use `jti` is NOT consumed (the whole exchange rolls back), so a later legitimate exchange with the same token still works. This is the same `evaluateSessionMint` decision the portal-login rail applies.\n\n**The claims GroundIT reads (`08 §14.4`).** The workspace uid comes from **`wsp`** — the claim the portal actually mints (`saas-portal/backend/src/utils/bridgeJwt.ts`: `wsp: tpa.workspaceUid`). It sends no claim called `tenantUid`; that name was GroundIT's own and is retained only as a fallback for first-party tooling (`wsp` wins if both appear). The principal is resolved **by email**: the `email` claim when present, else the workspace's cached `owner_email` — because the portal OMITS `email` for OWNER tokens on purpose (\"the product keeps resolving the workspace owner email\"), so \"no email claim\" is the contract's way of saying \"this is the owner\". That fallback is gated on TWO conditions: the `email` claim must be genuinely ABSENT (a present-but-unparseable value — `''`, whitespace-only, no `@` — is a MALFORMED token and answers `401`, never \"owner\"), AND the token must ASSERT ownership via `role: 'owner'` (any casing). `role` is non-optional on the minting side and defaults to `owner`, so every genuine owner token satisfies both, and no member token can reach the fallback; `role` is otherwise NOT an authorization input. All three identity lookups additionally require `deleted_at IS NULL`, so a soft-deleted principal resolves on no path. `sub` is `String(account.id)`, the portal's **Account** id: it is recorded on the identity as `platform_account_id` (an audit back-reference that joins to the portal's own `bridge.token.mint` log) and is **never** the lookup key — it is minted on this rail alone, the member-sync rail sends no member id at all, and the owner is never mirrored through that rail, so resolving on it would authenticate nobody. `slug`, `role`, `branchIds`, `readonly`, `actAsType` and `actAsEntityId` are accepted and ignored: GroundIT's authorization comes from `admin.member_grants`, not from a claim the token asserts about itself. Identity remains **bind-only** — an email matching no principal in the workspace is `401`, never a get-or-create.\n",
        "tags": [
          "platform",
          "sso"
        ],
        "x-token": "platform.sso.exchange",
        "x-realizes-features": [
          "XC-F03"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.tenant_cache",
          "xc.identities",
          "xc.sessions"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SsoExchangeInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Session minted; product session cookie set.",
            "headers": {
              "Set-Cookie": {
                "description": "Product session cookie (HttpOnly, Secure, SameSite=Lax) — the web session credential from here on (ADR 0009 §d \"mint a GroundIT product session (cookie)\").\n",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SsoExchangeResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "410": {
            "$ref": "#/components/responses/Gone"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/payment-settings": {
      "get": {
        "operationId": "platform.payment_settings.get",
        "summary": "Read a workspace's Razorpay BYOK configuration (masked)",
        "description": "The masked view behind the portal's product **Payment** settings tab (`ProductClient.getPaymentSettings`: `GET /platform/payment-settings?workspaceUid=…`). `razorpayKeyId` is returned MASKED (`rzp_live_••••1234`) and the two credentials are reduced to boolean has-flags — the plaintext of neither is held, let alone returned.\n\nA workspace that has never configured anything answers the UNSET shape with `200`, not a `404`: \"not configured yet\" is the tab's normal first state and it must still render. `404` is reserved for a workspace GroundIT has never been provisioned for.\n\n`webhookUrl` is always `null`: GroundIT serves no Razorpay webhook endpoint, and echoing a plausible URL that 404s would invite an owner to point their gateway at nothing. `verifiedAt`/`lastUsedAt` are `null` for the same reason — nothing here verifies or spends these credentials, and a fabricated stamp would claim otherwise.\n",
        "tags": [
          "platform",
          "settings"
        ],
        "x-token": "platform.payment_settings.get",
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.tenant_payment_settings",
          "xc.tenant_cache"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "tenantUid",
            "in": "query",
            "required": false,
            "description": "Same identity as the `tenantUid` path parameter (file header note 2) — required on every RBAC-mirror list/search op since these endpoints carry no tenant path segment of their own.\n\n**Either `tenantUid` or `workspaceUid` MUST be present** (neither → `422`); `tenantUid` wins when both are sent. Neither is marked `required: true` because OpenAPI cannot express \"exactly one of these two parameters\" — the same reason `MemberUpsertInput` expresses the pair as an `anyOf` on the body. This is the query-string half of the two-names rule that already governs the membership bodies (`08 §14.7`).\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "workspaceUid",
            "in": "query",
            "required": false,
            "description": "The PORTAL's name for the same workspace (`tpa.workspaceUid`) — an accepted alias of `tenantUid`, not a second identity. Its own clients call these endpoints with it (`ProductClient.listUsers` / `listRoles` / `listBranches` all build `?workspaceUid=…`), so accepting only GroundIT's spelling made the control plane's real calls fail on a field name alone (`08 §14.7`).\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The workspace's masked payment configuration (the unset shape when never configured).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PaymentSettings"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The workspace or its master account is suspended (`TENANT_SUSPENDED`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "404": {
            "description": "No such workspace in GroundIT (`error: tenant_not_found`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "410": {
            "description": "The workspace is cancelled (`TENANT_CANCELLED`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "patch": {
        "operationId": "platform.payment_settings.upsert",
        "summary": "Push a workspace's Razorpay BYOK configuration",
        "description": "`ProductClient.upsertPaymentSettings` — the owner's save on the portal's Payment tab. Appends `xc.platform_events` `event_type='PAYMENT_SETTINGS_UPSERT'` (migration 0058) and returns the freshly-merged, MASKED settings, so the tab renders persisted state rather than its own input.\n\n**ABSENT ≠ CLEARED.** A field the caller omits keeps its stored value. There is no \"clear\" spelling, deliberately: the portal's own zod schemas accept non-empty strings only, so it cannot express one, and treating an omission as a delete would blank a workspace's configuration on any partial save. An EMPTY body (no settings field named) is a `422`, not a successful no-op — it asserted nothing and must not burn an idempotency key.\n\n**SECRETS ARE ACCEPTED AND DISCARDED.** `razorpayKeySecret` / `razorpayWebhookSecret` are reduced to a presence sentinel at the framework boundary — before validation, before the DTO instance exists, and therefore before the append-only `xc.platform_events` payload snapshot and the 24 h idempotency cache are taken. What is stored is a deterministic secret-store reference key per slot; the plaintext exists nowhere in GroundIT. This is the same treatment `passwordHash` gets on this surface (`08 §14.5`).\n\n`422` when `isActive: true` would leave the workspace with no key id or no key-secret reference — a gateway cannot be switched on with nothing to switch on (`tenant_payment_settings_active_has_credential`).\n",
        "tags": [
          "platform",
          "settings"
        ],
        "x-token": "platform.payment_settings.upsert",
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.tenant_payment_settings",
          "xc.platform_events",
          "xc.tenant_cache"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PaymentSettingsUpsertInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Applied. The body reports PERSISTED, masked state, so an omitted field visibly kept its previous value.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PaymentSettings"
                }
              }
            }
          },
          "400": {
            "description": "`Idempotency-Key` absent.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The workspace or its master account is suspended (`TENANT_SUSPENDED`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "404": {
            "description": "No such workspace in GroundIT (`error: tenant_not_found`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "410": {
            "description": "The workspace is cancelled (`TENANT_CANCELLED`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "423": {
            "description": "The subscription is past due — writes are blocked while reads stay allowed (`TENANT_PAST_DUE`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/payment-settings/test": {
      "post": {
        "operationId": "platform.payment_settings.test",
        "summary": "Verify a workspace's Razorpay configuration",
        "description": "`ProductClient.testPaymentSettings`. **This operation cannot succeed in GroundIT today, and deliberately does not pretend to.** It writes nothing (no ledger row, no state change); the `Idempotency-Key` the auth guard requires on every unsafe verb is accepted and unused, which is safe precisely because there is nothing to replay.\n\nTwo independent reasons, neither a matter of effort:\n\n1. **GroundIT holds no credential to present.** The key secret never enters the system — the row\n   records a secret-store reference (see the `patch` above), and `@groundit/shared`'s\n   `SecretsClient` is read-only with a boot-time, deployment-wide name allowlist, so there is no\n   write path that could have put an owner's key there and no per-tenant read path that could\n   fetch it.\n2. **There is no Razorpay integration to call from.** Razorpay appears nowhere in this codebase.\n   ADR 0013 puts KSA money movement on `interpay`, ADR 0018 splits the money surfaces, and the\n   master overview §2.1 assigns billing to the control plane and tells GroundIT not to rebuild\n   it. Opening a live outbound rail to a payment provider from the API request path is a new\n   external integration — an ADR decision, not a controller detail.\n\n\nSo it answers a problem+json that says so: `409` when nothing is configured, `501` when the configuration is complete but unverifiable. Both carry `error: razorpay_test_failed`, which is the portal's own code for this tab (`mapProductError` → `400` + our `message`). Neither status is in the portal's retryable set (`0/429/502/503/504`), so a refusal is not retried six times. The `200` below is declared for the day a real verification exists.\n",
        "tags": [
          "platform",
          "settings"
        ],
        "x-token": "platform.payment_settings.test",
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.tenant_payment_settings",
          "xc.tenant_cache"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": "Always refuses today — see the description. Revisit if/when an ADR adds an outbound payment-gateway rail.",
        "parameters": [
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SettingsTestInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reserved. Not returned by any current deployment — see the description.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsTestResult"
                }
              }
            }
          },
          "400": {
            "description": "`Idempotency-Key` absent.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The workspace or its master account is suspended (`TENANT_SUSPENDED`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "404": {
            "description": "No such workspace in GroundIT (`error: tenant_not_found`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "409": {
            "description": "Razorpay is not configured for this workspace (`error: razorpay_test_failed`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "410": {
            "description": "The workspace is cancelled (`TENANT_CANCELLED`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "501": {
            "description": "Configured, but GroundIT performs no live gateway verification (`error: razorpay_test_failed`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          }
        }
      }
    },
    "/platform/email-settings": {
      "get": {
        "operationId": "platform.email_settings.get",
        "summary": "Read a workspace's transactional-email BYOK configuration",
        "description": "`ProductClient.getEmailSettings`. All the non-secret routing and presentation config is returned in full; the two credentials (`resendApiKey`, `smtpPass`) are reduced to boolean has-flags, exactly as on the payment tab and for the same reason. An unconfigured workspace answers the unset shape with `200` (`smtpSecure: true`, `bccOwner: false` — the column defaults, which are also the portal's own).\n",
        "tags": [
          "platform",
          "settings"
        ],
        "x-token": "platform.email_settings.get",
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.tenant_email_settings",
          "xc.tenant_cache"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "tenantUid",
            "in": "query",
            "required": false,
            "description": "Same identity as the `tenantUid` path parameter (file header note 2) — required on every RBAC-mirror list/search op since these endpoints carry no tenant path segment of their own.\n\n**Either `tenantUid` or `workspaceUid` MUST be present** (neither → `422`); `tenantUid` wins when both are sent. Neither is marked `required: true` because OpenAPI cannot express \"exactly one of these two parameters\" — the same reason `MemberUpsertInput` expresses the pair as an `anyOf` on the body. This is the query-string half of the two-names rule that already governs the membership bodies (`08 §14.7`).\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "workspaceUid",
            "in": "query",
            "required": false,
            "description": "The PORTAL's name for the same workspace (`tpa.workspaceUid`) — an accepted alias of `tenantUid`, not a second identity. Its own clients call these endpoints with it (`ProductClient.listUsers` / `listRoles` / `listBranches` all build `?workspaceUid=…`), so accepting only GroundIT's spelling made the control plane's real calls fail on a field name alone (`08 §14.7`).\n",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The workspace's email configuration (the unset shape when never configured).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmailSettings"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The workspace or its master account is suspended (`TENANT_SUSPENDED`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "404": {
            "description": "No such workspace in GroundIT (`error: tenant_not_found`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "410": {
            "description": "The workspace is cancelled (`TENANT_CANCELLED`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "patch": {
        "operationId": "platform.email_settings.upsert",
        "summary": "Push a workspace's transactional-email BYOK configuration",
        "description": "`ProductClient.upsertEmailSettings`. Appends `xc.platform_events` `event_type='EMAIL_SETTINGS_UPSERT'` (migration 0058). Same merge semantics as the payment sibling (absent ≠ cleared; empty body → `422`) and the same accept-and-discard treatment of the two credentials.\n",
        "tags": [
          "platform",
          "settings"
        ],
        "x-token": "platform.email_settings.upsert",
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.tenant_email_settings",
          "xc.platform_events",
          "xc.tenant_cache"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EmailSettingsUpsertInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Applied. The body reports PERSISTED state.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmailSettings"
                }
              }
            }
          },
          "400": {
            "description": "`Idempotency-Key` absent.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The workspace or its master account is suspended (`TENANT_SUSPENDED`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "404": {
            "description": "No such workspace in GroundIT (`error: tenant_not_found`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "410": {
            "description": "The workspace is cancelled (`TENANT_CANCELLED`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "423": {
            "description": "The subscription is past due — writes are blocked while reads stay allowed (`TENANT_PAST_DUE`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/platform/email-settings/test": {
      "post": {
        "operationId": "platform.email_settings.test",
        "summary": "Queue a test message with a workspace's email configuration",
        "description": "`ProductClient.testEmailSettings`. **Accepts** the test send and answers `202` — it does not send inline, and it never fabricates a delivery.\n\n• **`202`** — accepted. In ONE `app_rw` transaction the API writes a `QUEUED` `xc.mail_messages` row\n  (`kind: admin.test_send`) and one `xc.email.requested { mail_message_id }` outbox event; the jobs-tier\n  dispatcher (ADR 0038) resolves the workspace's provider config, resolves the secret, sends, and\n  settles the row. `ok: true` therefore means **accepted for sending**, never delivered. The body\n  carries `status: QUEUED` and `mailMessageId` for correlation.\n\n\n• **`409` `error: email_not_configured`** — the stored block cannot produce a message. This is now\n  decided by `describeMailConfig` against the `MAIL_PROVIDERS` registry (the ADR 0038 replacement for\n  the old `isSendable()` heuristic), so it is exactly the same question `createMailClient` would ask,\n  and the `detail` names the MISSING FIELD NAMES (e.g. `smtpPort, smtpEncryption, fromAddress`). No\n  stored value is ever echoed, and the two credential slots are reported by name only.\n\n\n**`verifiedAt` IS NOT STAMPED BY THIS CALL.** It is echoed back exactly as stored. The dispatcher stamps it only after a provider accepts a message AND only when the workspace's OWN configuration was used — never when the deployment default sender stood in. A `202` asserts acceptance; `verifiedAt` remains the single assertion of a confirmed successful send.\n\n**The `501` refusal is retired.** It was true until ADR 0038: GroundIT held no copy of the credential, had no SMTP client or provider SDK in the dependency tree, and had no per-workspace mail sender. That ADR built the `MailClient` port, the per-tenant secret resolver and the jobs-tier dispatcher, so the refusal became false and is removed here in the same change.\n\nThe SSRF posture is UNCHANGED and is why this is a `202` rather than a `200`: the API request path still never opens a TCP connection to a tenant-supplied host — the jobs tier dials the relay.\n\n`toAddress` was accepted-and-unused; it is now the real recipient. Omitted, the message goes to the workspace owner's address from `xc.tenant_cache`, then the stored `replyToEmail`, then `fromAddress`. No `xc.platform_events` ledger row is written: a test send mutates no configuration, and a diagnostic an owner deliberately pressed twice sends twice.\n",
        "tags": [
          "platform",
          "settings"
        ],
        "x-token": "platform.email_settings.test",
        "x-realizes-features": [
          "XC-F02"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.tenant_email_settings",
          "xc.tenant_cache",
          "xc.mail_messages",
          "xc.outbox"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "async",
        "x-emits-event": "xc.email.requested",
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "X-Platform-Timestamp",
            "in": "header",
            "required": true,
            "description": "Request timestamp, checked against a ±300s replay window (ADR 0009 §b). Paired with the `platformHmac` signature (`X-Platform-Signature`) over `timestamp + \"\\n\" + METHOD + \"\\n\" + path + \"\\n\" + SHA256(body)` — file header note 3 on the header-name normalization from the ADR's original `X-SaasPortal-Timestamp`.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EmailSettingsTestInput"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted for sending. A `QUEUED` `xc.mail_messages` row and its `xc.email.requested` event were committed together; delivery is the jobs tier's job and is NOT asserted here. `verifiedAt` is returned unchanged — this call never stamps it.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsTestResult"
                }
              }
            }
          },
          "400": {
            "description": "`Idempotency-Key` absent.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The workspace or its master account is suspended (`TENANT_SUSPENDED`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "404": {
            "description": "No such workspace in GroundIT (`error: tenant_not_found`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "409": {
            "description": "Email is not configured for this workspace (`error: email_not_configured`). The `detail` names the missing required field NAMES from the `MAIL_PROVIDERS` registry — never a stored value.\n",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "410": {
            "description": "The workspace is cancelled (`TENANT_CANCELLED`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsProblem"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "smsRelayBearer": {
        "type": "http",
        "scheme": "bearer",
        "description": "Dedicated SMS relay service credential, at least 32 random bytes."
      },
      "platformHmac": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Platform-Signature",
        "description": "HMAC request signature for the inbound `/platform/*` surface only (ADR 0009) — sysm-saas is the caller. Timestamped, replay-window-checked; details in openapi/platform/ and doc 04 §3. Product app clients never use this scheme.\n"
      }
    },
    "schemas": {
      "HealthStatus": {
        "type": "object",
        "required": [
          "status",
          "version",
          "dbCheck"
        ],
        "additionalProperties": false,
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "OK",
              "DEGRADED"
            ]
          },
          "version": {
            "type": "string",
            "description": "Deployed GroundIT product version."
          },
          "dbCheck": {
            "type": "string",
            "enum": [
              "OK",
              "FAIL"
            ]
          }
        }
      },
      "TenantStatus": {
        "type": "string",
        "description": "`xc.tenant_cache.status` (db 14 §2) — the four values GroundIT stores and RETURNS. Requests use `TenantStatusWire`, which additionally accepts the portal's own lowercase vocabularies.\n",
        "enum": [
          "ACTIVE",
          "PAST_DUE",
          "SUSPENDED",
          "CANCELLED"
        ]
      },
      "TenantStatusWire": {
        "type": "string",
        "description": "The status vocabulary accepted on REQUESTS, case-insensitively. GroundIT stores four values (`TenantStatus`); the One portal speaks two richer lowercase vocabularies over the same wire — its workspace/TPA `status` (`pending_provisioning | trial | active | past_due | suspended | cancelled`, `docs/05 §1`) and its subscription lifecycle (`trialing | active | grace | past_due | suspended | cancelled | expired | halted`, `docs/26`). Every spelling is accepted and MAPPED; an unrecognised one is `422`, never silently defaulted. The mapping follows what each state MEANS for a product, not what it is called (`08 §14.1`):\n`active` · `trial` · `trialing` · `grace` → **ACTIVE**. `docs/05 §5` puts `trial` in the SAME login row as `active` (login ✅ · reads ✅ · writes ✅ until `trialEndsAt`), and `docs/26` calls `grace` \"period ended, within 7-day grace — all operations allowed; soft banner\". GroundIT has no TRIAL lifecycle state and is not adding one; the expiry rides in `trialEndsAt`.\n`past_due` → **PAST_DUE** (`docs/26`: \"GET allowed; POST/PUT/DELETE → 423\" — ours exactly).\n`suspended` · `halted` → **SUSPENDED** (reversible; `403`).\n`cancelled` · `expired` → **CANCELLED** (terminal; `410`).\n`pending_provisioning` → **SUSPENDED**, fail-closed: the workspace is not live yet, and \"we were never told it is live\" must never resolve to \"it is\". The next provision/sync flips it.\n`canceled`, `past-due` and `pastdue` are tolerated spelling variants.\n",
        "enum": [
          "ACTIVE",
          "PAST_DUE",
          "SUSPENDED",
          "CANCELLED",
          "active",
          "trial",
          "trialing",
          "grace",
          "past_due",
          "suspended",
          "cancelled",
          "expired",
          "halted",
          "pending_provisioning",
          "canceled",
          "past-due",
          "pastdue"
        ]
      },
      "MasterStatus": {
        "type": "string",
        "description": "xc.tenant_cache.master_status (db 14 §2) — master-tenant overlay.",
        "enum": [
          "ACTIVE",
          "SUSPENDED"
        ]
      },
      "LocalizationBlock": {
        "type": "object",
        "description": "The portal's per-tenant localization block, replicated WHOLE into `xc.tenant_cache.localization` rather than shredded into columns — it is the platform's shape and the platform may extend it, while GroundIT's own market/statutory behaviour comes from compliance packs (ADR 0005), never from here. Additional properties are therefore allowed and stored.\n",
        "additionalProperties": true,
        "properties": {
          "countryCode": {
            "type": "string"
          },
          "timezone": {
            "type": "string"
          },
          "displayCurrency": {
            "type": "string"
          },
          "dateFormat": {
            "type": "string"
          }
        }
      },
      "Limits": {
        "type": "object",
        "description": "Numeric plan-limit keys (`xc.tenant_cache.limits`, db 14 §2; ADR 0009 §e) GroundIT publishes for the platform's plan builder. `-1` = unlimited; a key ABSENT from the map means the resource is not metered at all (which is NOT the same as `0`, a real cap of none). The four `max…` keys below are the ones GroundIT actually ENFORCES, each at the single create path that mints the resource — see the README's \"Plan limits and feature flags\" section and `08 §9`. Any other key is stored and echoed faithfully but gates nothing.\n",
        "properties": {
          "maxEmployees": {
            "type": "integer",
            "description": "Enforced at `people.employee.create`. -1 = unlimited."
          },
          "maxUsers": {
            "type": "integer",
            "description": "Enforced at `platform.member.upsert`. -1 = unlimited."
          },
          "maxLegalEntities": {
            "type": "integer",
            "description": "Enforced at `org.legal_entity.create`. -1 = unlimited."
          },
          "maxWorkLocations": {
            "type": "integer",
            "description": "Enforced at `org.work_location.create`. -1 = unlimited."
          },
          "maxStorageMb": {
            "type": "integer",
            "description": "REPORTED in usage, not enforced. -1 = unlimited."
          }
        },
        "additionalProperties": {
          "type": "integer",
          "description": "Forward-compatible additional limit keys (stored and echoed; not enforced)."
        }
      },
      "FeatureFlags": {
        "type": "object",
        "description": "Feature-flag map (`xc.tenant_cache.features`, db 14 §2) gating `402 FEATURE_NOT_IN_PLAN`.",
        "additionalProperties": {
          "type": "boolean"
        }
      },
      "EntitlementSnapshot": {
        "type": "object",
        "description": "The subscription/entitlement payload this surface reads and writes (assignment scope: \"subscription status, feature flags map, numeric limits\"). Mirrors `xc.tenant_cache` (db 14 §2).\n",
        "required": [
          "status",
          "limits",
          "features"
        ],
        "additionalProperties": false,
        "properties": {
          "status": {
            "$ref": "#/components/schemas/TenantStatus"
          },
          "masterStatus": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/MasterStatus"
              },
              {
                "type": "null"
              }
            ]
          },
          "planCode": {
            "type": [
              "string",
              "null"
            ]
          },
          "limits": {
            "$ref": "#/components/schemas/Limits"
          },
          "features": {
            "$ref": "#/components/schemas/FeatureFlags"
          },
          "subscriptionStartedAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          },
          "subscriptionExpiresAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "TenantCacheSummary": {
        "type": "object",
        "description": "The read projection of `xc.tenant_cache` (db 14 §2) returned by every tenant-lifecycle op.",
        "required": [
          "tenantUid",
          "status",
          "limits",
          "features",
          "storageBytesUsed",
          "syncedAt"
        ],
        "additionalProperties": false,
        "properties": {
          "tenantUid": {
            "$ref": "#/components/schemas/Uuid"
          },
          "masterTenantId": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Uuid"
              },
              {
                "type": "null"
              }
            ]
          },
          "workspaceSlug": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "$ref": "#/components/schemas/TenantStatus"
          },
          "masterStatus": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/MasterStatus"
              },
              {
                "type": "null"
              }
            ]
          },
          "planCode": {
            "type": [
              "string",
              "null"
            ]
          },
          "limits": {
            "$ref": "#/components/schemas/Limits"
          },
          "features": {
            "$ref": "#/components/schemas/FeatureFlags"
          },
          "subscriptionStartedAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          },
          "subscriptionExpiresAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          },
          "customDomain": {
            "type": [
              "string",
              "null"
            ]
          },
          "ownerName": {
            "type": [
              "string",
              "null"
            ]
          },
          "ownerEmail": {
            "type": [
              "string",
              "null"
            ]
          },
          "ownerMobile": {
            "type": [
              "string",
              "null"
            ]
          },
          "trialEndsAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          },
          "currentSubscriptionStatus": {
            "type": [
              "string",
              "null"
            ]
          },
          "currentPeriodEnd": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          },
          "localization": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/LocalizationBlock"
              },
              {
                "type": "null"
              }
            ]
          },
          "storageBytesUsed": {
            "type": "integer",
            "description": "Maintained counter",
            "not a filesystem scan.": null
          },
          "syncedAt": {
            "$ref": "#/components/schemas/Timestamp"
          }
        }
      },
      "ProvisionInput": {
        "type": "object",
        "description": "Body of `platform.tenant.provision` — the One portal's `ProvisionRequest` in full (`08 §14.1`), plus GroundIT's own original field names kept as accepted aliases.\n",
        "required": [
          "tenantUid",
          "status"
        ],
        "additionalProperties": false,
        "properties": {
          "tenantUid": {
            "$ref": "#/components/schemas/Uuid"
          },
          "masterTenantId": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Uuid"
              },
              {
                "type": "null"
              }
            ]
          },
          "workspaceSlug": {
            "type": [
              "string",
              "null"
            ],
            "description": "GroundIT's name for the routing slug. Wins over `subdomain` when both are sent."
          },
          "status": {
            "$ref": "#/components/schemas/TenantStatusWire"
          },
          "masterStatus": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/MasterStatus"
              },
              {
                "type": "null"
              }
            ]
          },
          "planCode": {
            "type": [
              "string",
              "null"
            ]
          },
          "limits": {
            "$ref": "#/components/schemas/Limits"
          },
          "features": {
            "$ref": "#/components/schemas/FeatureFlags"
          },
          "subscriptionStartedAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          },
          "subscriptionExpiresAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          },
          "subdomain": {
            "type": [
              "string",
              "null"
            ],
            "description": "The workspace subdomain label — stored as `workspace_slug`."
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255,
            "description": "Workspace display name. Written THROUGH to `admin.tenant_config[branding.company_name]`, not mirrored on the cache row (`08 §14.1`)."
          },
          "ownerName": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255
          },
          "ownerEmail": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 320,
            "description": "The workspace owner's email. **Load-bearing** (`08 §14.2/§14.4`): an OWNER bridge token omits the `email` claim by design, so `platform.sso.exchange` resolves the principal from this value. `provision` also CREATES that principal from it. Omit it and owner SSO cannot work.\n"
          },
          "ownerMobile": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 32
          },
          "customDomain": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255,
            "description": "Optional vanity host. The routing slug is `subdomain`, not this."
          },
          "trialEndsAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ],
            "description": "When `status: trial` stops allowing writes. `trial` replicates as `ACTIVE` with this instant carried."
          },
          "currentSubscriptionStatus": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 64,
            "description": "The portal's `Subscription.status` string, replicated VERBATIM and never interpreted — entitlement enforcement reads `status` (ADR 0009 §e), so a new value the portal invents cannot fail a check here.\n"
          },
          "currentPeriodEnd": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          },
          "localization": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/LocalizationBlock"
              },
              {
                "type": "null"
              }
            ]
          },
          "logoUrl": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 2048
          },
          "primaryColor": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 64
          },
          "credentialMode": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 32,
            "description": "How the portal wants the owner's first credential delivered. Meaningless here; retained in the ledger payload for audit."
          },
          "passwordHash": {
            "type": [
              "string",
              "null"
            ],
            "description": "**Accepted and DISCARDED at the framework boundary.** Never validated, never stored, never logged — dropped before the request body is snapshotted into the append-only `xc.platform_events` ledger or the 24 h idempotency cache. Declared only so the portal's real body validates.\n"
          }
        }
      },
      "TenantLifecycleReasonInput": {
        "type": "object",
        "description": "Optional context captured on suspend/resume/cancel for the `xc.platform_events` payload snapshot. The portal sends a per-action instant alongside the reason (`SuspendRequest.suspendedAt`, `ResumeRequest.resumedAt`, `CancelRequest.cancelledAt`); all three are accepted so its real bodies validate, and all three drive NOTHING. The authoritative instant for \"when GroundIT applied this\" is the ledger row's own `processed_at`, written in the same transaction as the state change — trusting a caller-supplied timestamp over the transaction clock would let a retried call rewrite history.\n",
        "additionalProperties": false,
        "properties": {
          "reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "suspendedAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          },
          "resumedAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          },
          "cancelledAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "PlanChangeInput": {
        "type": "object",
        "description": "Body of `platform.tenant.change_plan` — partial merge over `planCode`/`limits`/`features`. Mirrors the portal's `ApplyPlanRequest`, which also carries `effectiveAt` and `localization`.\n",
        "additionalProperties": false,
        "properties": {
          "planCode": {
            "type": [
              "string",
              "null"
            ]
          },
          "limits": {
            "$ref": "#/components/schemas/Limits"
          },
          "features": {
            "$ref": "#/components/schemas/FeatureFlags"
          },
          "effectiveAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ],
            "description": "When the new plan takes effect. Accepted for the ledger snapshot and deliberately NOT scheduled: `/platform/*` is a fast-ack seam (`08 §6`) and the platform is the system of record for billing dates — a plan is applied when the portal sends it, which is what the portal does.\n"
          },
          "localization": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/LocalizationBlock"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "SyncInput": {
        "type": "object",
        "description": "Body of `platform.tenant.sync` — the platform's full-state view of one workspace. Same field set as `ProvisionInput` minus `tenantUid` (which is in the path). Every portal-carried field is REPLACED wholesale, except branding, where an omitted field leaves the existing `admin.tenant_config` value alone (absent ≠ cleared — otherwise a routine reconcile would blank a tenant's theme). Unlike `provision`, sync does NOT create the owner principal: it is the drift-recovery rail and its `ownerEmail` is optional, so re-asserting an owner from a partial reconcile could resurrect a principal the portal had removed (`08 §14.2`).\n",
        "required": [
          "status"
        ],
        "additionalProperties": false,
        "properties": {
          "masterTenantId": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Uuid"
              },
              {
                "type": "null"
              }
            ]
          },
          "workspaceSlug": {
            "type": [
              "string",
              "null"
            ],
            "description": "GroundIT's name for the routing slug. Wins over `subdomain` when both are sent."
          },
          "status": {
            "$ref": "#/components/schemas/TenantStatusWire"
          },
          "masterStatus": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/MasterStatus"
              },
              {
                "type": "null"
              }
            ]
          },
          "planCode": {
            "type": [
              "string",
              "null"
            ]
          },
          "limits": {
            "$ref": "#/components/schemas/Limits"
          },
          "features": {
            "$ref": "#/components/schemas/FeatureFlags"
          },
          "subscriptionStartedAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          },
          "subscriptionExpiresAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          },
          "subdomain": {
            "type": [
              "string",
              "null"
            ],
            "description": "The workspace subdomain label — stored as `workspace_slug`."
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255,
            "description": "Workspace display name. Written THROUGH to `admin.tenant_config[branding.company_name]`, not mirrored on the cache row (`08 §14.1`)."
          },
          "ownerName": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255
          },
          "ownerEmail": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 320,
            "description": "The workspace owner's email. **Load-bearing** (`08 §14.2/§14.4`): an OWNER bridge token omits the `email` claim by design, so `platform.sso.exchange` resolves the principal from this value. `provision` also CREATES that principal from it. Omit it and owner SSO cannot work.\n"
          },
          "ownerMobile": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 32
          },
          "customDomain": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255,
            "description": "Optional vanity host. The routing slug is `subdomain`, not this."
          },
          "trialEndsAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ],
            "description": "When `status: trial` stops allowing writes. `trial` replicates as `ACTIVE` with this instant carried."
          },
          "currentSubscriptionStatus": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 64,
            "description": "The portal's `Subscription.status` string, replicated VERBATIM and never interpreted — entitlement enforcement reads `status` (ADR 0009 §e), so a new value the portal invents cannot fail a check here.\n"
          },
          "currentPeriodEnd": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          },
          "localization": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/LocalizationBlock"
              },
              {
                "type": "null"
              }
            ]
          },
          "logoUrl": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 2048
          },
          "primaryColor": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 64
          },
          "credentialMode": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 32,
            "description": "How the portal wants the owner's first credential delivered. Meaningless here; retained in the ledger payload for audit."
          },
          "passwordHash": {
            "type": [
              "string",
              "null"
            ],
            "description": "**Accepted and DISCARDED at the framework boundary.** Never validated, never stored, never logged — dropped before the request body is snapshotted into the append-only `xc.platform_events` ledger or the 24 h idempotency cache. Declared only so the portal's real body validates.\n"
          }
        }
      },
      "SubscriptionStatusInput": {
        "type": "object",
        "description": "Body of `platform.tenant.update_subscription_status` — the V1.5 signal. The portal names the field **`subscriptionStatus`**, not `status` (`SubscriptionStatusRequest`; `subscriptions.controller.ts` `pushStatusSync`), and sends its subscription vocabulary including `grace`. BOTH spellings are accepted and **exactly one must be present** (neither → `422`); `subscriptionStatus` wins when both are, being the portal's own field name. `graceStartedAt` and `trialEndsAt` ride along on the same call. `08 §14.1` has the full mapping.\n",
        "anyOf": [
          {
            "required": [
              "status"
            ]
          },
          {
            "required": [
              "subscriptionStatus"
            ]
          }
        ],
        "additionalProperties": false,
        "properties": {
          "status": {
            "$ref": "#/components/schemas/TenantStatusWire"
          },
          "subscriptionStatus": {
            "$ref": "#/components/schemas/TenantStatusWire"
          },
          "masterStatus": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/MasterStatus"
              },
              {
                "type": "null"
              }
            ]
          },
          "subscriptionStartedAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          },
          "subscriptionExpiresAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          },
          "currentPeriodEnd": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          },
          "trialEndsAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ]
          },
          "graceStartedAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ],
            "description": "When the 7-day grace window opened. Accepted for the ledger snapshot and given no column: `grace` replicates as `ACTIVE` (\"all operations allowed\", `docs/26`), so nothing here branches on when it started.\n"
          }
        }
      },
      "UsageReport": {
        "type": "object",
        "description": "Current usage against `limits`. The per-resource counts are LIVE reads of the product tables inside one tenant-scoped (FORCE-RLS) transaction — not a maintained counter — over exactly the predicates the create paths enforce against, so a count rendered here and a `402 PLAN_LIMIT_EXCEEDED` the product raised cannot disagree. `storageMb` is the exception: it is derived from the maintained `xc.tenant_cache.storage_bytes_used` counter, and is REPORTED but never enforced.\n",
        "required": [
          "tenantUid",
          "usage",
          "limits",
          "lastActivity",
          "lastSyncedAt",
          "asOf"
        ],
        "additionalProperties": false,
        "properties": {
          "tenantUid": {
            "$ref": "#/components/schemas/Uuid"
          },
          "usage": {
            "type": "object",
            "description": "Per-resource current counts. Each key is its `max…` limit key minus the `max` prefix, de-capitalised (`maxEmployees` ↔ `employees`) — the portal's own pairing convention, so its Plan & Usage page can render count-against-limit. See `08 §9` for the counted population behind each number.\n",
            "required": [
              "employees",
              "users",
              "legalEntities",
              "workLocations",
              "storageMb"
            ],
            "properties": {
              "employees": {
                "type": "integer",
                "description": "ACTIVE + ON_LEAVE + SUSPENDED; excludes EXITED/ALUMNI."
              },
              "users": {
                "type": "integer",
                "description": "Live workspace-member seats; excludes DEACTIVATED and the synthetic impersonation principals."
              },
              "legalEntities": {
                "type": "integer",
                "description": "ACTIVE + INACTIVE; excludes DISSOLVED."
              },
              "workLocations": {
                "type": "integer",
                "description": "ACTIVE + INACTIVE (no terminal state)."
              },
              "storageMb": {
                "type": "integer",
                "description": "Derived from storage_bytes_used. Reported, not enforced."
              }
            },
            "additionalProperties": {
              "type": "integer"
            }
          },
          "limits": {
            "$ref": "#/components/schemas/Limits"
          },
          "lastActivity": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ],
            "description": "Newest `xc.sessions.last_seen_at` in the workspace; `null` when it has never been used."
          },
          "lastSyncedAt": {
            "$ref": "#/components/schemas/Timestamp"
          },
          "asOf": {
            "$ref": "#/components/schemas/Timestamp"
          }
        }
      },
      "PurgeInput": {
        "type": "object",
        "additionalProperties": false,
        "description": "The optional purge body the portal sends (`docs/05 §2.6`). Stored in the append-only platform ledger; the confirmation flow that produced it is enforced at saas-portal, not here.\n",
        "properties": {
          "reason": {
            "type": "string",
            "nullable": true,
            "description": "Operator-supplied justification."
          },
          "confirmedAt": {
            "$ref": "#/components/schemas/Timestamp"
          },
          "confirmTenantUid": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255,
            "description": "The portal's typed-confirmation echo (`PurgeRequest.confirmTenantUid`). Accepted so its real body validates; it gates nothing — the typed-name confirmation and the owner-role check are enforced at saas-portal, and re-deriving a second gate from a field the same caller supplies would be theatre. Lands in the append-only ledger payload with the rest of the justification.\n"
          }
        }
      },
      "PurgeResult": {
        "type": "object",
        "required": [
          "tenantUid",
          "purgedAt"
        ],
        "additionalProperties": false,
        "properties": {
          "tenantUid": {
            "$ref": "#/components/schemas/Uuid"
          },
          "purgedAt": {
            "$ref": "#/components/schemas/Timestamp"
          }
        }
      },
      "BrandingUpdateInput": {
        "type": "object",
        "description": "Body of `platform.tenant.update_branding` — the portal's `BrandingUpdateRequest` (exactly `{ logoUrl, primaryColor }`) plus `name`, the third key the same `admin.tenant_config` write-through owns. **At least one property is required** (an empty object → `422`): a PATCH that names no field asserted nothing, and treating it as a successful no-op would burn an idempotency key on a caller bug.\n\n**ABSENT ≠ CLEARED.** An omitted property leaves the stored value untouched; an explicit `null` clears it. The portal always sends both of its two keys, using `null` where the workspace has none, so a cleared theme is something it asserts rather than something an omission implies.\n",
        "minProperties": 1,
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255,
            "description": "Workspace display name → `admin.tenant_config['branding.company_name']`."
          },
          "logoUrl": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 2048,
            "description": "Absolute logo URL → `admin.tenant_config['branding.logo_url']`."
          },
          "primaryColor": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 64,
            "description": "Brand colour (hex) → `admin.tenant_config['branding.primary_color']`."
          }
        }
      },
      "Branding": {
        "type": "object",
        "description": "The three branding facts as `admin.tenant_config` holds them. `null` means unset — a key never pushed and a key explicitly cleared are the same thing to the session projection that reads them.\n",
        "required": [
          "name",
          "logoUrl",
          "primaryColor"
        ],
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ]
          },
          "logoUrl": {
            "type": [
              "string",
              "null"
            ]
          },
          "primaryColor": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "BrandingUpdateResult": {
        "type": "object",
        "description": "Response of `platform.tenant.update_branding`. `ok` is the portal's own declared field (`BrandingUpdateResponse` is exactly `{ ok: boolean }`), kept so its client sees the shape it expects. `branding` is additive and is read back INSIDE the mutation's transaction, so it reports persisted state rather than echoing the request — which is what makes \"absent ≠ cleared\" observable on the wire instead of only claimed in prose.\n",
        "required": [
          "ok",
          "tenantUid",
          "branding"
        ],
        "additionalProperties": false,
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          },
          "tenantUid": {
            "$ref": "#/components/schemas/Uuid"
          },
          "branding": {
            "$ref": "#/components/schemas/Branding"
          }
        }
      },
      "ImpersonateInput": {
        "type": "object",
        "required": [
          "actingPrincipal"
        ],
        "additionalProperties": false,
        "properties": {
          "actingPrincipal": {
            "type": "string",
            "description": "Platform super-admin / delegated-admin identity requesting impersonation."
          },
          "reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "ttlSeconds": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Optional session TTL override; server default applies when omitted."
          }
        }
      },
      "ImpersonationSession": {
        "type": "object",
        "required": [
          "tokenId",
          "sessionToken",
          "expiresAt"
        ],
        "additionalProperties": false,
        "properties": {
          "tokenId": {
            "$ref": "#/components/schemas/Uuid"
          },
          "sessionToken": {
            "type": "string",
            "description": "Opaque handle the portal redirects the impersonated view with."
          },
          "redirectUrl": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri-reference"
          },
          "expiresAt": {
            "$ref": "#/components/schemas/Timestamp"
          }
        }
      },
      "ImpersonationRevokeResult": {
        "type": "object",
        "required": [
          "tokenId",
          "revokedAt"
        ],
        "additionalProperties": false,
        "properties": {
          "tokenId": {
            "$ref": "#/components/schemas/Uuid"
          },
          "revokedAt": {
            "$ref": "#/components/schemas/Timestamp"
          }
        }
      },
      "IdentityStatus": {
        "type": "string",
        "description": "xc.identities.status (db 14 §1).",
        "enum": [
          "INVITED",
          "ACTIVE",
          "SUSPENDED",
          "DEACTIVATED"
        ]
      },
      "MemberUpsertInput": {
        "type": "object",
        "description": "Body of `platform.member.upsert` — **both wire shapes** (`08 §14.3`).\nThe One portal addresses a member by `(workspaceUid, email)`, names the display field `name`, expresses privilege as `coarseRole` + `roleHint`, sends NO status and NO member id, and carries five fields shaped for other products on the same control plane. GroundIT's original shape uses `(tenantUid, platformMemberId)` + `status` + `roleKeys`. Both are accepted; GroundIT's own field name WINS wherever both are present.\n**Identity key.** `xc.identities` is keyed `(tenant_id, platform_member_id)`. When the caller supplies no `platformMemberId` it is DERIVED from the email as `email:<lower(trim(email))>` — email is the only identifier the control plane shares across its rails (its member-sync payload carries no id, and the owner is never mirrored through that rail at all). The derivation is deterministic, so `provision`'s owner and a later member push for the same person land on ONE row. A caller may not assert an `email:`-prefixed `platformMemberId` directly (`422`) — that would let it take over a derived identity.\n**Either `platformMemberId` or a usable `email` is required** (neither → `422`), as is either `tenantUid` or `workspaceUid`. `status` defaults to `ACTIVE`: the portal has no status field and calls `DELETE` when access is withdrawn, so \"the portal pushed this member\" already means \"this member may sign in\".\n",
        "anyOf": [
          {
            "required": [
              "tenantUid"
            ]
          },
          {
            "required": [
              "workspaceUid"
            ]
          }
        ],
        "additionalProperties": false,
        "properties": {
          "tenantUid": {
            "$ref": "#/components/schemas/Uuid"
          },
          "workspaceUid": {
            "$ref": "#/components/schemas/Uuid",
            "description": "The portal's name for the target workspace (`tpa.workspaceUid`). Equivalent to `tenantUid`."
          },
          "platformMemberId": {
            "type": "string",
            "maxLength": 255,
            "description": "`xc.identities.platform_member_id` (db 14 §1). OPTIONAL — derived from `email` when absent. May not begin with `email:`, `platform-admin:`, nor equal the impersonation sentinel.\n"
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 320,
            "description": "The address the portal addresses this member by, and the key-derivation source."
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 32,
            "description": "E.164."
          },
          "displayName": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255,
            "description": "GroundIT's field name. Wins over `name`."
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255,
            "description": "The portal's field name for the display name."
          },
          "status": {
            "$ref": "#/components/schemas/IdentityStatus"
          },
          "roleKeys": {
            "type": "array",
            "maxItems": 200,
            "description": "The `admin.roles.role_key` set the platform is granting this member (grant storage is the security round's, ADR 0010 §e). **Present means authoritative**, and an explicit `[]` is a deliberate revoke-all. **ABSENT is not \"revoke\"** — if `coarseRole`/`roleHint` are present the set is derived from them, and if none of the three is present existing grants are left EXACTLY as they are (`08 §14.3`).\n",
            "items": {
              "type": "string"
            }
          },
          "coarseRole": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 64,
            "description": "The portal's coarse workspace role — `owner` | `admin` | `member`. Maps to `owner ⇒ [TENANT_ADMIN, HR_ADMIN]`, `admin ⇒ [TENANT_ADMIN]`, `member ⇒ [EMPLOYEE]` (the least-privilege self-service baseline, never the empty set). An unrecognised value falls back to the member baseline. Used only when `roleKeys` is absent.\n"
          },
          "roleHint": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 80,
            "description": "The product role the granting admin picked out of `GET /platform/role-catalog`, matched case-insensitively against the workspace's LIVE catalogue and ADDED to the `coarseRole` floor (so an owner who is also a recruiter stays an owner). For a plain `member` a matched hint REPLACES the baseline. An unrecognised hint is logged and IGNORED, never fatal — the portal's grant editor carries free-text roles for other products, and refusing a member over a typo would break provisioning.\n"
          },
          "branchIds": {
            "type": "array",
            "maxItems": 500,
            "description": "Branch scoping. Ignored: grants on this seam are workspace-scoped (`admin.member_grants.subject_kind = 'WORKSPACE_MEMBER'`) and the portal sends `[]` for GroundIT. Narrowing a grant to a branch is a role-scope concern (security-docs/03), not a mirror concern.\n",
            "items": {}
          },
          "bankIds": {
            "type": "array",
            "maxItems": 500,
            "items": {},
            "description": "VerifyOn bank-hierarchy mapping. Ignored."
          },
          "accessType": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 32,
            "description": "Which product identity to provision. GroundIT has one kind. Ignored."
          },
          "entityId": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Bank/region/branch hierarchy id. Ignored."
          },
          "identities": {
            "type": "array",
            "maxItems": 100,
            "items": {},
            "description": "VerifyOn multi-identity reconciliation set. Ignored — GroundIT mirrors one principal per member."
          }
        }
      },
      "MemberUpsertResult": {
        "type": "object",
        "required": [
          "identityId",
          "userId",
          "tenantUid",
          "platformMemberId",
          "status"
        ],
        "additionalProperties": false,
        "properties": {
          "identityId": {
            "$ref": "#/components/schemas/Uuid"
          },
          "userId": {
            "$ref": "#/components/schemas/Uuid",
            "description": "**ALIAS of `identityId` — the same uuid, not a second identifier.** The portal's client reads `{ userId, roleId, branchId, status }` and caches `userId`; it tolerated the field's absence (caching nothing), and echoing it costs nothing and removes a live divergence (`08 §14.6`). `identityId` remains the canonical name and the only one anything resolves by: `userId` is a rendering of it at the wire boundary, so the two can never drift. Do not key off it.\n"
          },
          "tenantUid": {
            "$ref": "#/components/schemas/Uuid"
          },
          "platformMemberId": {
            "type": "string"
          },
          "status": {
            "$ref": "#/components/schemas/IdentityStatus"
          },
          "linkedEmployeeId": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Uuid"
              },
              {
                "type": "null"
              }
            ],
            "description": "Always null on this seam. A member is mirrored with no employee subject, and the app_platform role holds no privilege on the people schema, so the link (when one exists) is projected by GET /api/v1/auth/session, not here."
          }
        }
      },
      "MemberDeleteInput": {
        "type": "object",
        "description": "Body of `platform.member.delete` — both wire shapes. The portal sends `{ workspaceUid, email }` (`WorkspaceMemberDeleteRequest`); GroundIT's own shape sends `{ tenantUid, platformMemberId }`. Either workspace field and either member field is required (neither → `422`); the member key is derived from `email` exactly as on upsert, so a delete addresses the same row the upsert created (`08 §14.3`).\n",
        "anyOf": [
          {
            "required": [
              "tenantUid"
            ]
          },
          {
            "required": [
              "workspaceUid"
            ]
          }
        ],
        "additionalProperties": false,
        "properties": {
          "tenantUid": {
            "$ref": "#/components/schemas/Uuid"
          },
          "workspaceUid": {
            "$ref": "#/components/schemas/Uuid",
            "description": "The portal's name for the target workspace. Equivalent to `tenantUid`."
          },
          "platformMemberId": {
            "type": "string",
            "maxLength": 255,
            "description": "OPTIONAL — derived from `email` when absent. Reserved prefixes are rejected."
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 320
          }
        }
      },
      "MemberDeleteResult": {
        "type": "object",
        "required": [
          "tenantUid",
          "platformMemberId",
          "status"
        ],
        "additionalProperties": false,
        "properties": {
          "tenantUid": {
            "$ref": "#/components/schemas/Uuid"
          },
          "platformMemberId": {
            "type": "string"
          },
          "status": {
            "$ref": "#/components/schemas/IdentityStatus"
          }
        }
      },
      "RoleCatalogStatus": {
        "type": "string",
        "description": "admin.role_catalog_status (db 02 §2.1).",
        "enum": [
          "ACTIVE",
          "DEPRECATED"
        ]
      },
      "RoleStatus": {
        "type": "string",
        "description": "admin.role_status (db 02 §2.1).",
        "enum": [
          "ACTIVE",
          "INACTIVE"
        ]
      },
      "RoleScopeType": {
        "type": "string",
        "description": "admin.role_scope (db 02 §2.1). Ladder: PLATFORM > TENANT > LEGAL_ENTITY > ORG_UNIT ≥ BRANCH > SELF.",
        "enum": [
          "PLATFORM",
          "TENANT",
          "LEGAL_ENTITY",
          "ORG_UNIT",
          "BRANCH",
          "SELF"
        ]
      },
      "PermissionStatus": {
        "type": "string",
        "description": "admin.permission_status (db 02 §2.1).",
        "enum": [
          "ACTIVE",
          "DEPRECATED"
        ]
      },
      "BranchType": {
        "type": "string",
        "description": "admin.branch_type (db 02 §2.1).",
        "enum": [
          "HEAD_OFFICE",
          "REGIONAL",
          "BRANCH"
        ]
      },
      "BranchStatus": {
        "type": "string",
        "description": "admin.branch_status (db 02 §2.1).",
        "enum": [
          "ACTIVE",
          "INACTIVE"
        ]
      },
      "RoleCatalogEntry": {
        "type": "object",
        "description": "Projection of admin.role_catalog (db 02 §2.1).",
        "required": [
          "id",
          "catalogKey",
          "name",
          "isSystem",
          "status"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "catalogKey": {
            "type": "string"
          },
          "name": {
            "$ref": "#/components/schemas/LocalizedText"
          },
          "description": {
            "$ref": "#/components/schemas/LocalizedText"
          },
          "isSystem": {
            "type": "boolean"
          },
          "defaultPermissions": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "status": {
            "$ref": "#/components/schemas/RoleCatalogStatus"
          }
        }
      },
      "RoleCatalogPage": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CursorPage"
          },
          {
            "type": "object",
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/RoleCatalogEntry"
                }
              }
            }
          }
        ]
      },
      "RoleEntry": {
        "type": "object",
        "description": "Projection of admin.roles (db 02 §2.1).",
        "required": [
          "id",
          "roleKey",
          "name",
          "scopeType",
          "isSystem",
          "status"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "roleCatalogId": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Uuid"
              },
              {
                "type": "null"
              }
            ]
          },
          "roleKey": {
            "type": "string"
          },
          "name": {
            "$ref": "#/components/schemas/LocalizedText"
          },
          "scopeType": {
            "$ref": "#/components/schemas/RoleScopeType"
          },
          "isSystem": {
            "type": "boolean"
          },
          "status": {
            "$ref": "#/components/schemas/RoleStatus"
          }
        }
      },
      "RolePage": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CursorPage"
          },
          {
            "type": "object",
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/RoleEntry"
                }
              }
            }
          }
        ]
      },
      "PermissionEntry": {
        "type": "object",
        "description": "Projection of admin.permissions (db 02 §2.1).",
        "required": [
          "id",
          "permissionKey",
          "module",
          "isPrivileged",
          "status"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "permissionKey": {
            "type": "string",
            "description": "dot-namespaced `<module>.<entity>.<action>`."
          },
          "module": {
            "type": "string"
          },
          "description": {
            "$ref": "#/components/schemas/LocalizedText"
          },
          "isPrivileged": {
            "type": "boolean"
          },
          "status": {
            "$ref": "#/components/schemas/PermissionStatus"
          }
        }
      },
      "PermissionPage": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CursorPage"
          },
          {
            "type": "object",
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/PermissionEntry"
                }
              }
            }
          }
        ]
      },
      "BranchGeo": {
        "type": "object",
        "description": "admin.branches.geo JSONB shape (db 02 §2.1) — geo-scoping, never joined.",
        "additionalProperties": false,
        "properties": {
          "region": {
            "type": [
              "string",
              "null"
            ]
          },
          "state": {
            "type": [
              "string",
              "null"
            ]
          },
          "city": {
            "type": [
              "string",
              "null"
            ]
          },
          "postalCode": {
            "type": [
              "string",
              "null"
            ]
          },
          "lat": {
            "type": [
              "number",
              "null"
            ]
          },
          "lng": {
            "type": [
              "number",
              "null"
            ]
          }
        }
      },
      "BranchEntry": {
        "type": "object",
        "description": "Projection of admin.branches (db 02 §2.1).",
        "required": [
          "id",
          "branchCode",
          "name",
          "type",
          "status"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "branchCode": {
            "type": "string"
          },
          "name": {
            "$ref": "#/components/schemas/LocalizedText"
          },
          "parentBranchId": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Uuid"
              },
              {
                "type": "null"
              }
            ]
          },
          "legalEntityId": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Uuid"
              },
              {
                "type": "null"
              }
            ]
          },
          "type": {
            "$ref": "#/components/schemas/BranchType"
          },
          "geo": {
            "$ref": "#/components/schemas/BranchGeo"
          },
          "status": {
            "$ref": "#/components/schemas/BranchStatus"
          }
        }
      },
      "BranchPage": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CursorPage"
          },
          {
            "type": "object",
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/BranchEntry"
                }
              }
            }
          }
        ]
      },
      "PrincipalType": {
        "type": "string",
        "description": "xc.identities.principal_type (db 14 §1) — the class of principal a mirrored row represents.",
        "enum": [
          "EMPLOYEE",
          "APPLICANT",
          "WORKSPACE_MEMBER",
          "SERVICE"
        ]
      },
      "UserListEntry": {
        "type": "object",
        "description": "One row of `platform.user.list`. A SUPERSET of `UserSearchResult`, not a copy: the search op resolves ONE person for member-linking (hence `principal_type='EMPLOYEE'` and a required term), while this op enumerates the workspace's principals, so it must say which KIND each one is and when it appeared. `displayName`/`employeeNo` stay null until the People module's projection lands (db-docs/00 §3).\n",
        "required": [
          "identityId",
          "principalType",
          "status",
          "createdAt"
        ],
        "additionalProperties": false,
        "properties": {
          "identityId": {
            "$ref": "#/components/schemas/Uuid"
          },
          "principalType": {
            "$ref": "#/components/schemas/PrincipalType"
          },
          "employeeId": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Uuid"
              },
              {
                "type": "null"
              }
            ],
            "description": "The linked subject (`xc.identities.subject_id`) — an employee id for an EMPLOYEE principal."
          },
          "platformMemberId": {
            "type": [
              "string",
              "null"
            ],
            "description": "`xc.identities.platform_member_id` — how the control plane addresses this principal."
          },
          "employeeNo": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/BusinessNo"
              },
              {
                "type": "null"
              }
            ]
          },
          "displayName": {
            "type": [
              "string",
              "null"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ]
          },
          "phone": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "$ref": "#/components/schemas/IdentityStatus"
          },
          "lastLoginAt": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ],
            "description": "Last successful sign-in, or null if the principal has never signed in."
          },
          "createdAt": {
            "$ref": "#/components/schemas/Timestamp",
            "description": "When the principal was mirrored into GroundIT. Display data — the keyset runs on `identityId` (see the operation description)."
          }
        }
      },
      "UserListPage": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CursorPage"
          },
          {
            "type": "object",
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/UserListEntry"
                }
              }
            }
          }
        ]
      },
      "UserSearchResult": {
        "type": "object",
        "description": "Projection of `xc.identities` resolved against the `people.employees` read-model where `principal_type='EMPLOYEE'` (db 14 §1; db-docs/00 §3 — own projection, never the owning module's write model).\n",
        "required": [
          "identityId",
          "status"
        ],
        "additionalProperties": false,
        "properties": {
          "identityId": {
            "$ref": "#/components/schemas/Uuid"
          },
          "employeeId": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Uuid"
              },
              {
                "type": "null"
              }
            ]
          },
          "employeeNo": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/BusinessNo"
              },
              {
                "type": "null"
              }
            ]
          },
          "displayName": {
            "type": [
              "string",
              "null"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ]
          },
          "phone": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "$ref": "#/components/schemas/IdentityStatus"
          }
        }
      },
      "UserSearchPage": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CursorPage"
          },
          {
            "type": "object",
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/UserSearchResult"
                }
              }
            }
          }
        ]
      },
      "SsoExchangeInput": {
        "type": "object",
        "required": [
          "token"
        ],
        "additionalProperties": false,
        "properties": {
          "token": {
            "type": "string",
            "description": "The platform's 60-second single-use Ed25519 JWT (compact serialization), verified per this operation's description (ADR 0009 §d).\n"
          }
        }
      },
      "SsoExchangeResult": {
        "type": "object",
        "required": [
          "sessionId",
          "identityId",
          "tenantUid",
          "expiresAt"
        ],
        "additionalProperties": false,
        "properties": {
          "sessionId": {
            "$ref": "#/components/schemas/Uuid"
          },
          "identityId": {
            "$ref": "#/components/schemas/Uuid"
          },
          "tenantUid": {
            "$ref": "#/components/schemas/Uuid"
          },
          "redirectUrl": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri-reference",
            "description": "SPA landing route, ADR 0009 §a \"/_/sso?token=\"."
          },
          "expiresAt": {
            "$ref": "#/components/schemas/Timestamp"
          }
        }
      },
      "SettingsProblem": {
        "description": "The standard RFC 9457 problem, PLUS two extension members the six BYOK-settings routes carry. Nothing is removed and `code` is unchanged — a GroundIT-native client reads exactly what it read before. The additions exist because the One portal's proxy for these routes branches on `responseBody.error ?? responseBody.code` in a lowercase vocabulary and renders `responseBody.message`, so a bare `code: \"NOT_FOUND\"` would surface to a workspace owner as \"Could not reach the product\" (`08 §14.8`). This surface conforms to the platform's shape rather than re-casing it, exactly as it already does for camelCase field names (file header note 1).\n",
        "allOf": [
          {
            "$ref": "#/components/schemas/Problem"
          },
          {
            "type": "object",
            "properties": {
              "error": {
                "type": "string",
                "description": "The portal's code for this failure — the string its `mapProductError` switches on.\n",
                "enum": [
                  "tenant_not_found",
                  "feature_locked",
                  "razorpay_test_failed",
                  "email_test_failed",
                  "email_not_configured"
                ]
              },
              "message": {
                "type": "string",
                "description": "The same text as `detail`, under the field name the portal renders."
              }
            }
          }
        ]
      },
      "PaymentSettings": {
        "description": "The portal's `PaymentSettingsResponse`. `razorpayKeyId` is MASKED and the credentials appear only as has-flags — GroundIT holds neither plaintext (`08 §14.8`).\n",
        "type": "object",
        "required": [
          "isActive",
          "razorpayKeyId",
          "hasKeySecret",
          "hasWebhookSecret",
          "razorpayAccountId",
          "currency",
          "verifiedAt",
          "lastUsedAt",
          "webhookUrl"
        ],
        "additionalProperties": false,
        "properties": {
          "isActive": {
            "type": "boolean",
            "description": "Whether the workspace has switched its gateway on. `true` requires a key id and a key-secret reference."
          },
          "razorpayKeyId": {
            "type": [
              "string",
              "null"
            ],
            "description": "MASKED (`rzp_live_••••1234`) — the visible prefix is everything through the last `_` (the zero-entropy `rzp_live_` / `rzp_test_` discriminator, so an owner can tell which key they pasted), then the last four characters of the body. A partial mask is only used when at least four characters OF THE BODY stay hidden; anything shorter is returned fully masked (`••••`) rather than disclosed behind decorative dots. The plaintext key id is never returned, per the portal's own type comment (\"Plaintext never returned\").\n"
          },
          "hasKeySecret": {
            "type": "boolean",
            "description": "A secret-store reference is recorded for the key secret — i.e. the owner declared one."
          },
          "hasWebhookSecret": {
            "type": "boolean",
            "description": "As `hasKeySecret`, for the webhook secret."
          },
          "razorpayAccountId": {
            "type": [
              "string",
              "null"
            ]
          },
          "currency": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO-4217, exactly 3 characters. Nullable and never defaulted: India and KSA launch co-equal (ADR 0005), so silently writing `INR` for a Saudi workspace would be a market bias.\n"
          },
          "verifiedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Always `null` today — nothing in GroundIT verifies these credentials, and a fabricated stamp would claim otherwise."
          },
          "lastUsedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Always `null` today — nothing in GroundIT spends these credentials."
          },
          "webhookUrl": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Always `null`: GroundIT serves no Razorpay webhook endpoint. The portal's stub synthesises one; echoing a plausible URL that 404s would invite an owner to point their gateway at nothing.\n"
          }
        }
      },
      "PaymentSettingsUpsertInput": {
        "description": "The portal's `PaymentSettingsUpsertRequest`. Exactly ONE of `tenantUid` / `workspaceUid` is required (`tenantUid` wins) — the body half of the two-names rule (`08 §14.3`/`§14.7`); OpenAPI cannot express \"one of these two\", hence the `anyOf`. At least one settings field must also be named: a PATCH that asserts nothing is a `422`, not a no-op that burns an idempotency key.\n",
        "type": "object",
        "additionalProperties": false,
        "anyOf": [
          {
            "required": [
              "tenantUid"
            ]
          },
          {
            "required": [
              "workspaceUid"
            ]
          }
        ],
        "properties": {
          "tenantUid": {
            "$ref": "#/components/schemas/Uuid"
          },
          "workspaceUid": {
            "$ref": "#/components/schemas/Uuid"
          },
          "isActive": {
            "type": "boolean"
          },
          "razorpayKeyId": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          },
          "razorpayKeySecret": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500,
            "writeOnly": true,
            "description": "ACCEPTED AND DISCARDED. Reduced to a presence flag at the framework boundary — before validation, before the DTO instance exists, and therefore before the append-only `xc.platform_events` payload snapshot and the 24 h idempotency cache. Only a deterministic secret-store reference key is persisted; the plaintext exists nowhere in GroundIT.\n"
          },
          "razorpayWebhookSecret": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500,
            "writeOnly": true,
            "description": "As `razorpayKeySecret` — accepted, never stored."
          },
          "razorpayAccountId": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "ISO-4217."
          }
        }
      },
      "EmailSettings": {
        "description": "The portal's `EmailSettingsResponse`. Every field except the two has-flags is non-secret routing or presentation config and is returned in full.\n",
        "type": "object",
        "required": [
          "provider",
          "hasResendApiKey",
          "smtpHost",
          "smtpPort",
          "smtpUser",
          "hasSmtpPass",
          "smtpSecure",
          "fromName",
          "fromAddress",
          "replyToEmail",
          "logoUrl",
          "primaryColor",
          "footerHtml",
          "bccOwner",
          "verifiedAt",
          "encryption"
        ],
        "additionalProperties": false,
        "properties": {
          "provider": {
            "type": [
              "string",
              "null"
            ],
            "description": "The owner's chosen provider label (e.g. `resend`, `smtp`). Stored verbatim; GroundIT does not interpret it."
          },
          "hasResendApiKey": {
            "type": "boolean",
            "description": "A secret-store reference is recorded for the API key."
          },
          "smtpHost": {
            "type": [
              "string",
              "null"
            ]
          },
          "smtpPort": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 65535
          },
          "smtpUser": {
            "type": [
              "string",
              "null"
            ]
          },
          "hasSmtpPass": {
            "type": "boolean",
            "description": "A secret-store reference is recorded for the SMTP password."
          },
          "smtpSecure": {
            "type": "boolean",
            "deprecated": true,
            "description": "RETAINED, still answered, and no longer the authority. Defaults to `true` for an unconfigured workspace (the column default, and the portal's). A boolean cannot express 587-STARTTLS vs 465-implicit-TLS, which is why ADR 0038 replaced it with `encryption`; the two are dual-written so they never disagree.\n"
          },
          "fromName": {
            "type": [
              "string",
              "null"
            ]
          },
          "fromAddress": {
            "type": [
              "string",
              "null"
            ],
            "format": "email"
          },
          "replyToEmail": {
            "type": [
              "string",
              "null"
            ],
            "format": "email"
          },
          "logoUrl": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri"
          },
          "primaryColor": {
            "type": [
              "string",
              "null"
            ]
          },
          "footerHtml": {
            "type": [
              "string",
              "null"
            ]
          },
          "bccOwner": {
            "type": "boolean"
          },
          "verifiedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "The last CONFIRMED successful send with this workspace's own configuration. Stamped by the jobs-tier dispatcher only when a provider accepted a message AND the tenant's own credentials were used — never when the deployment default sender stood in, and never by the test-send request itself (see `POST /platform/email-settings/test`). `null` until that happens.\n"
          },
          "encryption": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "STARTTLS",
              "TLS",
              "NONE",
              null
            ],
            "description": "ADDITIVE (migration 0110, ADR 0038). The transport posture the jobs tier dials with: `STARTTLS` → `{ secure: false, requireTLS: true }` (the 587 reference posture), `TLS` → `{ secure: true }` (465, implicit TLS), `NONE` → plain. `null` means the workspace has not declared one — three of the four providers in the registry (`ses`, `resend`, `stub`) have no encryption concept at all.\n"
          }
        }
      },
      "EmailSettingsUpsertInput": {
        "description": "The portal's `EmailSettingsUpsertRequest`. Same one-of-two workspace rule and same at-least-one-field rule as `PaymentSettingsUpsertInput`.\n",
        "type": "object",
        "additionalProperties": false,
        "anyOf": [
          {
            "required": [
              "tenantUid"
            ]
          },
          {
            "required": [
              "workspaceUid"
            ]
          }
        ],
        "properties": {
          "tenantUid": {
            "$ref": "#/components/schemas/Uuid"
          },
          "workspaceUid": {
            "$ref": "#/components/schemas/Uuid"
          },
          "provider": {
            "type": "string",
            "minLength": 1,
            "maxLength": 40
          },
          "resendApiKey": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500,
            "writeOnly": true,
            "description": "ACCEPTED AND DISCARDED — see `PaymentSettingsUpsertInput.razorpayKeySecret`."
          },
          "smtpHost": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255
          },
          "smtpPort": {
            "type": "integer",
            "minimum": 1,
            "maximum": 65535
          },
          "smtpUser": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255
          },
          "smtpPass": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500,
            "writeOnly": true,
            "description": "ACCEPTED AND DISCARDED — see `PaymentSettingsUpsertInput.razorpayKeySecret`."
          },
          "smtpSecure": {
            "type": "boolean",
            "deprecated": true,
            "description": "Accepted and still stored. When `encryption` is absent this is what the posture is DERIVED from, port-aware and exactly as migration 0110's backfill does it: `smtpSecure: false` → `NONE`; `smtpPort: 587` → `STARTTLS`; `smtpPort: 465` → `TLS`; otherwise `smtpSecure: true` → `TLS`, else `NONE`. A workspace with no `smtpHost` keeps `encryption: null`.\n"
          },
          "encryption": {
            "type": "string",
            "enum": [
              "STARTTLS",
              "TLS",
              "NONE"
            ],
            "description": "ADDITIVE and optional — the portal's current client type does not carry it. When present it WINS over `smtpSecure`; when absent the posture is derived (above). The dual-write runs BOTH ways: a PATCH that names `encryption` and not `smtpSecure` writes the boolean back as `encryption === 'TLS'`, so the deprecated column stays coherent for a client (or a rolled-back build) that still reads it. A PATCH that names no transport field at all leaves a previously-stored `encryption` untouched.\n"
          },
          "fromName": {
            "type": "string",
            "maxLength": 120
          },
          "fromAddress": {
            "type": "string",
            "format": "email",
            "maxLength": 255
          },
          "replyToEmail": {
            "type": "string",
            "format": "email",
            "maxLength": 255
          },
          "logoUrl": {
            "type": "string",
            "format": "uri",
            "maxLength": 1000
          },
          "primaryColor": {
            "type": "string",
            "maxLength": 20
          },
          "footerHtml": {
            "type": "string",
            "maxLength": 5000
          },
          "bccOwner": {
            "type": "boolean"
          }
        }
      },
      "SettingsTestInput": {
        "description": "The portal sends `{ workspaceUid }` and nothing else (`PaymentSettingsTestRequest`).",
        "type": "object",
        "additionalProperties": false,
        "anyOf": [
          {
            "required": [
              "tenantUid"
            ]
          },
          {
            "required": [
              "workspaceUid"
            ]
          }
        ],
        "properties": {
          "tenantUid": {
            "$ref": "#/components/schemas/Uuid"
          },
          "workspaceUid": {
            "$ref": "#/components/schemas/Uuid"
          }
        }
      },
      "EmailSettingsTestInput": {
        "description": "The portal's `EmailSettingsTestRequest`. `toAddress` is now the REAL recipient of the test message (ADR 0038); when it is omitted the message goes to the workspace owner's address from `xc.tenant_cache`, then the stored `replyToEmail`, then `fromAddress`.\n",
        "type": "object",
        "additionalProperties": false,
        "anyOf": [
          {
            "required": [
              "tenantUid"
            ]
          },
          {
            "required": [
              "workspaceUid"
            ]
          }
        ],
        "properties": {
          "tenantUid": {
            "$ref": "#/components/schemas/Uuid"
          },
          "workspaceUid": {
            "$ref": "#/components/schemas/Uuid"
          },
          "toAddress": {
            "type": "string",
            "format": "email",
            "maxLength": 255
          }
        }
      },
      "SettingsTestResult": {
        "description": "The portal's `SettingsTestResponse`, extended additively for the email test send (ADR 0038).\n\nFor `POST /platform/email-settings/test` this is the **202** body and `ok: true` means **accepted for sending** — never delivered. The API request path deliberately never opens a socket to a tenant-supplied host (`08 §14.8`'s SSRF posture), so it cannot know the outcome when it replies; the jobs-tier dispatcher settles the row (`QUEUED → SENDING → SENT | FAILED | SUPPRESSED`).\n\n`verifiedAt` remains the ONLY assertion of a confirmed successful send and is **not stamped by the test call** — it is echoed back exactly as stored. The payment sibling still never returns this schema.\n\n`status` and `mailMessageId` are new OPTIONAL members: a client reading only `{ ok, verifiedAt }` (the portal's current type) is unaffected.\n",
        "type": "object",
        "required": [
          "ok",
          "verifiedAt"
        ],
        "additionalProperties": false,
        "properties": {
          "ok": {
            "type": "boolean",
            "description": "Accepted for sending. NOT a delivery confirmation."
          },
          "verifiedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "The stored verification timestamp, unchanged by this call."
          },
          "status": {
            "type": "string",
            "enum": [
              "QUEUED"
            ],
            "description": "The state the `xc.mail_messages` row was created in. Email test send only."
          },
          "mailMessageId": {
            "allOf": [
              {
                "$ref": "#/components/schemas/Uuid"
              }
            ],
            "description": "The `xc.mail_messages` row minted for this test send, for later correlation."
          }
        }
      },
      "Uuid": {
        "type": "string",
        "format": "uuid",
        "description": "UUIDv7 surrogate primary key (db-docs/00 §3). Never the business identifier."
      },
      "Problem": {
        "type": "object",
        "description": "RFC 9457 problem detail (application/problem+json). The platform-wide error envelope (03 §1).",
        "required": [
          "type",
          "title",
          "status"
        ],
        "properties": {
          "type": {
            "type": "string",
            "format": "uri",
            "default": "about:blank",
            "description": "Problem-type URI."
          },
          "title": {
            "type": "string",
            "description": "Short, human-readable summary (stable per type)."
          },
          "status": {
            "type": "integer",
            "description": "HTTP status code, duplicated for convenience."
          },
          "detail": {
            "type": "string",
            "description": "Human-readable explanation specific to this occurrence."
          },
          "instance": {
            "type": "string",
            "description": "URI reference for this specific occurrence."
          },
          "code": {
            "$ref": "#/components/schemas/ErrorCode"
          },
          "correlation_id": {
            "$ref": "#/components/schemas/Uuid"
          }
        }
      },
      "Timestamp": {
        "type": "string",
        "format": "date-time",
        "description": "timestamptz, serialized UTC ISO-8601. Presentation timezone is a client concern."
      },
      "LocalizedText": {
        "type": "object",
        "description": "Locale-keyed content map (db-docs/00 §9) for localized/white-label text (designation titles, announcement bodies, template names). Keys are the legal entity's active locale set.\n",
        "properties": {
          "en": {
            "type": "string"
          },
          "ar": {
            "type": "string"
          }
        },
        "additionalProperties": {
          "type": "string"
        }
      },
      "CursorPage": {
        "type": "object",
        "description": "Generic cursor-pagination envelope. List operations compose it via allOf to type `data`, e.g. `allOf: [ {$ref CursorPage}, { properties: { data: { items: {$ref Employee} } } } ]`.\n",
        "required": [
          "data",
          "page"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {}
          },
          "page": {
            "type": "object",
            "required": [
              "has_more"
            ],
            "properties": {
              "next_cursor": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "prev_cursor": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "has_more": {
                "type": "boolean"
              },
              "total_est": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Optional, capped, APPROXIMATE row estimate for grid \"X of Z\" display only — never an exact COUNT(*) on large tables (attend.attendance_records, xc.notifications, audit.*).\n"
              }
            }
          }
        }
      },
      "BusinessNo": {
        "type": "string",
        "description": "Tenant-unique, prefixed human reference (`employee_no`, `requisition_no`, `offer_no`, `payslip_no`, `claim_no`, `ticket_no`, `asset_no`, `case_no`). Read-model field; never a path key.\n"
      },
      "ValidationProblem": {
        "description": "422 field-level validation failure; extends Problem with a per-field error array. `detail` is ALWAYS present on a 422 (#1251) and is the human summary of `errors[]`: one offending field renders as `\"<field>: <its message>\"` (`withholding_amount: is required for an India entity`), several as `\"N fields were refused: a, b, c.\"`, capped at five names. It is display copy derived from members already in the same body — clients keep branching on `code` and mapping `errors[].pointer` back to a control, never parsing this sentence.\n",
        "allOf": [
          {
            "$ref": "#/components/schemas/Problem"
          },
          {
            "type": "object",
            "required": [
              "detail",
              "errors"
            ],
            "properties": {
              "detail": {
                "type": "string",
                "description": "Human summary of `errors[]`, always populated on a 422 so a client never has to fall back to generic copy for the one status that names a fixable field.\n",
                "example": "withholding_amount: is required for an India entity"
              },
              "errors": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": [
                    "pointer",
                    "rule"
                  ],
                  "properties": {
                    "pointer": {
                      "type": "string",
                      "description": "JSON Pointer to the offending field, e.g. /claim_amount"
                    },
                    "rule": {
                      "type": "string",
                      "enum": [
                        "required",
                        "format",
                        "length",
                        "range",
                        "cross-field",
                        "async-server",
                        "consent-gated",
                        "uniqueness-business",
                        "not_found"
                      ],
                      "description": "FSD validation taxonomy rule (fsd-docs/00 §8.2). `not_found` is the server-side-lookup arm: a body field that REFERENCES another resource (e.g. `project_id` on a work entry) and did not resolve for this caller. It is reported here, under the field's pointer, and NOT as a 404 — the request addresses its own resource, so the failure belongs on the form field the client can actually fix (#805).\n"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        ]
      },
      "ErrorCode": {
        "type": "string",
        "description": "Machine-stable error code (paired with the HTTP status; drives client handling, not display copy).",
        "enum": [
          "VALIDATION_FAILED",
          "IDEMPOTENCY_KEY_REUSE",
          "VERSION_CONFLICT",
          "PRECONDITION_REQUIRED",
          "MAKER_EQUALS_CHECKER",
          "STEP_UP_REQUIRED",
          "CONSENT_REQUIRED",
          "TOKEN_DENIED",
          "SCOPE_DENIED",
          "OWNERSHIP_DENIED",
          "STATE_TRANSITION_INVALID",
          "FACE_MISMATCH",
          "FACE_VERIFICATION_REQUIRED",
          "WITHHOLDING_REVIEW_REQUIRED",
          "FEATURE_NOT_IN_PLAN",
          "PLAN_LIMIT_EXCEEDED",
          "TENANT_PAST_DUE",
          "TENANT_SUSPENDED",
          "TENANT_CANCELLED",
          "RATE_LIMITED",
          "NOT_FOUND"
        ]
      }
    },
    "parameters": {
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": true,
        "description": "Client-generated unique key. REQUIRED on every mutation (this round tightens ADR 0015's \"platform + retryable mutations\" floor to ALL mutations for uniformity — offline punch/leave sync depends on it). Scoped (tenant, principal, route, key); a replay within the ~24h window returns the stored response with `Idempotency-Replayed: true`; the same key with a different body → 409 IDEMPOTENCY_KEY_REUSE (04 §1).\n",
        "schema": {
          "type": "string",
          "minLength": 8,
          "maxLength": 255
        }
      },
      "PageSize": {
        "name": "page[size]",
        "in": "query",
        "required": false,
        "description": "Max items per page. Cursor pagination only (03 §2); offset pagination is rejected (ADR 0015).",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 200,
          "default": 50
        }
      },
      "PageAfter": {
        "name": "page[after]",
        "in": "query",
        "required": false,
        "description": "Opaque forward keyset cursor (from a prior page's `page.next_cursor`).",
        "schema": {
          "type": "string"
        }
      },
      "PageBefore": {
        "name": "page[before]",
        "in": "query",
        "required": false,
        "description": "Opaque backward keyset cursor (from a prior page's `page.prev_cursor`).",
        "schema": {
          "type": "string"
        }
      },
      "PathId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "UUIDv7 surrogate key of the target resource. Business numbers (`employee_no`, `claim_no`, …) are read-model fields, never path keys.",
        "schema": {
          "$ref": "#/components/schemas/Uuid"
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing, expired, or invalid session token (no authenticated principal).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "NotFound": {
        "description": "Resource does not exist OR is masked by RLS (tenant/self/team/branch scope) — the API does not distinguish, so existence is never confirmed across a scope boundary (02 §4 disclosure posture).\n",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "Forbidden": {
        "description": "Authenticated but denied — permission token not granted, out of scope (self/team/branch), not the owner, tenant suspended, or an MC-2 operation without a fresh step-up challenge. `code` ∈ TOKEN_DENIED | SCOPE_DENIED | OWNERSHIP_DENIED | MAKER_EQUALS_CHECKER | STEP_UP_REQUIRED | CONSENT_REQUIRED | TENANT_SUSPENDED. A plan feature-flag being off is 402 FEATURE_NOT_IN_PLAN, not 403 (see PaymentRequired).\n",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "Gone": {
        "description": "Tenant cancelled/purged (ADR 0009 V1.5 lifecycle). `code` = TENANT_CANCELLED. Login and all product calls are blocked.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "Conflict": {
        "description": "Idempotency-key reuse with a different body, an invalid state transition, or a uniqueness/business-rule violation (fsd 00 §8.2). For database-backed uniqueness rules, detail names the exact violated rule; unmapped constraints are not collapsed into a generic 409.\n",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "UnprocessableEntity": {
        "description": "Field-level validation failed. The body carries both `errors[]` (per-field, pointer-addressed) and a `detail` summarising them — never an empty `detail` (#1251).\n",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/ValidationProblem"
            },
            "examples": {
              "singleField": {
                "summary": "One refused field — `detail` names it",
                "value": {
                  "type": "about:blank",
                  "title": "Unprocessable Entity",
                  "status": 422,
                  "code": "VALIDATION_FAILED",
                  "detail": "withholding_amount: is required for an India entity",
                  "errors": [
                    {
                      "pointer": "/withholding_amount",
                      "rule": "required",
                      "message": "is required for an India entity"
                    }
                  ]
                }
              },
              "severalFields": {
                "summary": "Several refused fields — `detail` counts and names them",
                "value": {
                  "type": "about:blank",
                  "title": "Unprocessable Entity",
                  "status": 422,
                  "code": "VALIDATION_FAILED",
                  "detail": "2 fields were refused: effective_from, cap_amount.amount.",
                  "errors": [
                    {
                      "pointer": "/effective_from",
                      "rule": "cross-field",
                      "message": "Overlaps an existing version."
                    },
                    {
                      "pointer": "/cap_amount/amount",
                      "rule": "range",
                      "message": "Must be a non-negative decimal string."
                    }
                  ]
                }
              }
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Per-principal/route rate limit exceeded.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      }
    },
    "headers": {
      "IdempotencyReplayed": {
        "description": "`true` when a stored idempotent response was replayed rather than freshly computed.",
        "schema": {
          "type": "boolean"
        }
      },
      "Location": {
        "description": "URI of the created/affected resource.",
        "schema": {
          "type": "string",
          "format": "uri-reference"
        }
      },
      "RetryAfter": {
        "description": "Seconds to wait before retrying (on 429 / 503).",
        "schema": {
          "type": "integer"
        }
      }
    }
  }
}