{
  "openapi": "3.1.0",
  "info": {
    "title": "GroundIT — Cross-Cutting (XC)",
    "version": "0.1.0",
    "license": {
      "name": "Proprietary — GroundIT/Sysmedac internal"
    },
    "description": "Web back-office authentication & session bootstrap (`/auth/*` + the pre-auth workspace-slug lookup), notifications & FCM device-token registration, session logout, the portal SSE live-update stream, the generic object-storage upload/download seam, role-aware dashboard read-models, the unified cross-module approvals inbox with delegation/act-on-behalf, global search, the async jobs-tier run log (`xc.*`), and the read-only access-log / domain-event-journal / decision-artifact-evidence lenses (`audit.*`) this file hosts per the keystone's file-composition table. See ../../api-docs/00-api-overview-and-conventions.md.\n"
  },
  "servers": [
    {
      "url": "/api/v1"
    }
  ],
  "security": [
    {
      "bearerJWT": []
    }
  ],
  "tags": [
    {
      "name": "xc",
      "description": "Cross-cutting services every module leans on — notifications, files, dashboard, approvals, search, jobs."
    },
    {
      "name": "notification",
      "description": "Per-recipient notification delivery record & read-state (xc.notifications)."
    },
    {
      "name": "device-token",
      "description": "FCM push-token registration against the caller's session."
    },
    {
      "name": "auth",
      "description": "Web back-office authentication & session bootstrap — password sign-in, sign-out, and the session projection the portal shell renders from (XC-S02/S06; api-docs 02 §1/§6). Anonymous or any-principal by construction; these declare no permission token.\n"
    },
    {
      "name": "workspace",
      "description": "Pre-authentication workspace-slug resolution for the sign-in page (xc.tenant_cache, read-only)."
    },
    {
      "name": "session",
      "description": "Session lifecycle actions this surface owns (logout)."
    },
    {
      "name": "event-stream",
      "description": "Portal SSE live-update stream (ADR 0016)."
    },
    {
      "name": "file",
      "description": "Generic presigned object-storage upload/download seam (xc.object_refs, XC-F07)."
    },
    {
      "name": "dashboard",
      "description": "Role-aware home read-models (xc.dashboard_projections, xc.home_widgets)."
    },
    {
      "name": "approval-inbox",
      "description": "Unified cross-module approvals queue (xc.approval_inbox) — read + act-routing only."
    },
    {
      "name": "delegation",
      "description": "Date-bounded delegation / act-on-behalf (xc.delegations)."
    },
    {
      "name": "approval-rule",
      "description": "Approval routing configuration — chains, amount bands, step timeouts and escalation targets (xc.approval_rules, XC-F16, ADR 0027). Configured on ADM-S09, consumed by the XC-S15 inbox. Routes and records only; it holds no authorization authority of its own.\n"
    },
    {
      "name": "search",
      "description": "Cross-module global search & command palette (xc.search_index)."
    },
    {
      "name": "job-run",
      "description": "Async jobs-tier execution log, read-only (xc.job_runs)."
    },
    {
      "name": "audit",
      "description": "Append-only evidence-plane read lenses hosted here (audit.access_log, audit.domain_events, audit.decision_artifacts)."
    }
  ],
  "x-reuse-anchors": {
    "parameters": {
      "path_id": {
        "$ref": "#/components/parameters/PathId"
      },
      "page_size": {
        "$ref": "#/components/parameters/PageSize"
      },
      "page_after": {
        "$ref": "#/components/parameters/PageAfter"
      },
      "page_before": {
        "$ref": "#/components/parameters/PageBefore"
      },
      "idempotency_key": {
        "$ref": "#/components/parameters/IdempotencyKey"
      },
      "if_match": {
        "$ref": "#/components/parameters/IfMatch"
      },
      "correlation_id": {
        "$ref": "#/components/parameters/CorrelationId"
      }
    },
    "responses": {
      "unauthorized": {
        "$ref": "#/components/responses/Unauthorized"
      },
      "forbidden": {
        "$ref": "#/components/responses/Forbidden"
      },
      "not_found": {
        "$ref": "#/components/responses/NotFound"
      },
      "gone": {
        "$ref": "#/components/responses/Gone"
      },
      "conflict": {
        "$ref": "#/components/responses/Conflict"
      },
      "unprocessable": {
        "$ref": "#/components/responses/UnprocessableEntity"
      },
      "locked": {
        "$ref": "#/components/responses/Locked"
      },
      "too_many": {
        "$ref": "#/components/responses/TooManyRequests"
      },
      "service_unavailable": {
        "description": "Identity provider temporarily unavailable. Retry after the `Retry-After` delay.",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            },
            "description": "Seconds before retrying."
          }
        },
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "precondition_required": {
        "$ref": "#/components/responses/PreconditionRequired"
      },
      "precondition_failed": {
        "$ref": "#/components/responses/PreconditionFailed"
      }
    },
    "headers": {
      "etag": {
        "$ref": "#/components/headers/ETag"
      },
      "location": {
        "$ref": "#/components/headers/Location"
      },
      "idem_replayed": {
        "$ref": "#/components/headers/IdempotencyReplayed"
      }
    }
  },
  "paths": {
    "/notifications": {
      "get": {
        "operationId": "xc.notification.list",
        "summary": "List the caller's notification-centre feed",
        "description": "The in-app notification centre (fsd 02 XC-S13; db 14 §3 `xc.notifications`) — searchable over `title`/`body`, filterable by `channel`/`status`/`category` (the last via the same-schema `notification_templates` join).\n\n**`channel` defaults to `IN_APP` (#1629).** Omitting the parameter returns the INBOX — the durable in-app rows and nothing else — and any unread count you derive from this read counts those same rows. It is not an unfiltered read: the other `channel` members are transport records, and a client that omitted the parameter used to list them beside the inbox items and show every notification twice. Pass `?channel=` explicitly only to look at a transport lane on purpose. (Related and already deployed: the projection consumer no longer writes an `xc.notifications` row for `SSE` at all — an SSE frame is `xc.sse_events`, delivered over `GET /events/stream`.)\n\n**Known gap:** the FSD's domain tabs (Attendance/Leave/Tasks/Payroll/ Announcements) have no matching column on either `notifications` or `notification_templates.category` (`WORKFLOW`/`APPROVAL`/`REMINDER`/`OTP`/`ANNOUNCEMENT`/`ALERT`) — `category` is exposed as-is, the FSD's domain tag is not invented (fsd 02 XC-S13 field table). Sort whitelist: `created_at` (default `-created_at`).\n",
        "tags": [
          "xc",
          "notification"
        ],
        "x-token": "xc.notification.list",
        "x-realizes-features": [
          "XC-F05"
        ],
        "x-screens": [
          "XC-S13"
        ],
        "x-touches-entities": [
          "xc.notifications",
          "xc.notification_templates"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Client search over `title`/`body`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "channel",
            "in": "query",
            "required": false,
            "description": "Defaults to `IN_APP` — the notification centre's own rows (#1629). Supply a value only to inspect a transport lane deliberately; there is no \"all channels\" value.\n",
            "schema": {
              "type": "string",
              "enum": [
                "PUSH",
                "IN_APP",
                "SSE"
              ],
              "default": "IN_APP"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/NotificationStatus"
            }
          },
          {
            "name": "category",
            "in": "query",
            "required": false,
            "description": "Filters via the parent `notification_templates.category` (same-schema join, db 14 §3).",
            "schema": {
              "$ref": "#/components/schemas/NotificationCategory"
            }
          },
          {
            "name": "created_at[from]",
            "in": "query",
            "required": false,
            "description": "Delta-sync affordance (GAP-38, #966): return only notifications created on or after this instant, so the PWA's reconnect pull can ask for what is new since `lastSyncAt` instead of refetching the whole feed. `xc.notifications` is append-only, so `created_at` — already the feed's sort key — is the delta axis; a read-state change (mark-read) is not reflected here.\n",
            "schema": {
              "$ref": "#/components/schemas/TimestampRef"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `created_at`, `-created_at`. Default `-created_at`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of the caller's notification-centre rows.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/notifications/{id}": {
      "get": {
        "operationId": "xc.notification.get",
        "summary": "Get one notification (deep-link open)",
        "description": "A single notification-centre row, resolved on a push-tap deep link (fsd 02 XC-S13).",
        "tags": [
          "xc",
          "notification"
        ],
        "x-token": "xc.notification.get",
        "x-realizes-features": [
          "XC-F05"
        ],
        "x-screens": [
          "XC-S13"
        ],
        "x-touches-entities": [
          "xc.notifications"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "The notification.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Notification"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/notifications/{id}/read": {
      "post": {
        "operationId": "xc.notification.mark_read",
        "summary": "Mark one notification as read",
        "description": "Row-tap read receipt (fsd 02 XC-S13) — `status='SENT'|'DELIVERED' → 'READ'`, stamps `read_at`. Unversioned, low-contention entity (single-recipient-owned) — no `If-Match` (`_shared.yaml` IfMatch carve-out).\n",
        "tags": [
          "xc",
          "notification"
        ],
        "x-token": "xc.notification.mark_read",
        "x-realizes-features": [
          "XC-F05"
        ],
        "x-screens": [
          "XC-S13"
        ],
        "x-touches-entities": [
          "xc.notifications"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": "xc.notification.marked_read",
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "Notification marked read.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Notification"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "description": "Topic broker temporarily unavailable; client keeps focus revalidation fallback active.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/notifications/mark-all-read": {
      "post": {
        "operationId": "xc.notification.mark_all_read",
        "summary": "Mark all of the caller's unread notifications as read",
        "description": "Bulk read receipt (fsd 02 XC-S13 **\"Mark all as read\"**) — `IN_APP` channel rows only. `Idempotency-Key` is required even when there are no unread rows; send a client-generated key of at least 8 characters and reuse it for retries. Missing or invalid keys return the standard `422` validation problem.\n",
        "tags": [
          "xc",
          "notification"
        ],
        "x-token": "xc.notification.mark_all_read",
        "x-realizes-features": [
          "XC-F05"
        ],
        "x-screens": [
          "XC-S13"
        ],
        "x-touches-entities": [
          "xc.notifications"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": "xc.notification.bulk_marked_read",
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "Count of rows updated.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarkAllReadResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/device-tokens": {
      "post": {
        "operationId": "xc.device_token.register",
        "summary": "Register/update the caller's FCM push token",
        "description": "Binds an FCM push token to the caller's current session's `device_info.fcm_token` (db 14 §1 `xc.sessions`) — the target the notifications engine dispatches `PUSH` deliveries to (XC-F05, ADR 0016). Upsert; unversioned, low-contention entity (self-owned session) — no `If-Match`.\n",
        "tags": [
          "xc",
          "device-token"
        ],
        "x-token": "xc.device_token.register",
        "x-realizes-features": [
          "XC-F05"
        ],
        "x-screens": [
          "XC-S13"
        ],
        "x-touches-entities": [
          "xc.sessions"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": "xc.device_token.registered",
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DeviceTokenRegisterInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Push token registered against the caller's current session.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeviceTokenRegisterResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "412": {
            "$ref": "#/components/responses/PreconditionFailed"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/push-subscriptions": {
      "post": {
        "operationId": "xc.push_subscription.register",
        "summary": "Register the caller's Web Push subscription",
        "description": "Stores the browser's Push API subscription (`endpoint`, `p256dh`, `auth`) against the caller's IDENTITY in `xc.push_subscriptions` (ADR 0058 §a) — the target the jobs-tier dispatcher sends IMMEDIATE-class notifications to. Keyed to the identity, not the session, so an installed PWA keeps receiving notifications across the session rotation of ADR 0057. Upsert on `(tenant, identity, endpoint)`: the browser hands back the same endpoint on every load, and re-registering a previously revoked one resurrects it rather than creating a second row. The response carries no subscription material. **`endpoint` must name a recognised browser push service** (`fcm.googleapis.com`, `*.push.services.mozilla.com`, `*.push.apple.com`, `*.notify.windows.com`, ...); anything else is `422`, because this URL is one a background worker later opens a socket to and an unconstrained host would be an SSRF primitive.\n",
        "tags": [
          "xc",
          "push-subscription"
        ],
        "x-token": "xc.push_subscription.register",
        "x-realizes-features": [
          "XC-F05"
        ],
        "x-screens": [
          "XC-S13"
        ],
        "x-touches-entities": [
          "xc.push_subscriptions"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": "xc.push_subscription.registered",
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PushSubscriptionRegisterInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Subscription registered against the caller's identity.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PushSubscriptionRegisterResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "delete": {
        "operationId": "xc.push_subscription.revoke",
        "summary": "Revoke the caller's Web Push subscription for one endpoint",
        "description": "Marks the caller's subscription for `endpoint` revoked (`revoked_at`), which is what the client calls when the user turns notifications off or the browser reports the subscription changed. Scoped to the CALLER's identity: one person can never revoke another's registration, including on a shared handset where both hold the same endpoint. Idempotent and non-disclosing — always `204`, whether or not a live row matched, so the response cannot be used to probe which endpoints are registered. Rows are never deleted; a revocation is a timestamp.\n",
        "tags": [
          "xc",
          "push-subscription"
        ],
        "x-token": "xc.push_subscription.revoke",
        "x-realizes-features": [
          "XC-F05"
        ],
        "x-screens": [
          "XC-S13"
        ],
        "x-touches-entities": [
          "xc.push_subscriptions"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": "xc.push_subscription.revoked",
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PushSubscriptionRevokeInput"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Subscription revoked",
            "or there was nothing live to revoke.": null
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/auth/identify": {
      "post": {
        "operationId": "xc.auth.identify",
        "summary": "Decide the second step of an email-first sign-in",
        "description": "Step ONE of the identifier-first login rail (issue #1411, ADR 0068; fsd 02 `XC-S06`). The sign-in page asks for an address and nothing else, posts it here, and this answers which screen comes next: a **password** box, or the **first-login set-up** flow. `POST /auth/login` is unchanged and still does the signing in — this is a routing decision taken before any secret is typed.\n\n**`next` HAS EXACTLY TWO VALUES AND THERE IS NO `UNKNOWN`.** An address the identity providers have never seen answers `PASSWORD`, and so does a back-office member whose password lives at Sysmedac One and whom no Keycloak realm knows at all — because `xc.auth.login` already answers every one of those with the same uniform `401` (`security-docs/05` THR-WEB-01, and the reason `POST /workspaces/lookup` was deleted). A third value would make this an account-enumeration oracle on an anonymous route. The screen after `PASSWORD` is a password box, which is exactly where an unknown address belongs.\n\n**The answer is the IdP's, not a GroundIT column.** GroundIT stores no password anywhere (ADR 0010), so \"already has a password\" means the realm user for this address holds a `password` credential. `SETUP` is returned **only** when a realm user exists and holds none — the state a GroundIT-provisioned ESS subject is created in. Every provisioned realm is probed **in parallel**, never in sequence, so response time does not disclose which region the account belongs to (the same reasoning `xc.auth.login`'s `market` carries). `market`, when sent, narrows the probe to one realm.\n\n**`workspace` additionally enables lazy ESS provisioning** (ADR 0068 D3(b)). When the slug resolves and that workspace holds an `xc.identities` row that is `EMPLOYEE`, `INVITED`, un-deleted, carries this exact address and has no `keycloak_sub`, the realm subject is created here and written onto that row — so an employee added through Quick Add minutes ago can sign in immediately rather than waiting for the jobs-tier sweep. **That is the entire authority boundary**: a realm user is created only for an identity a token-holding human already provisioned, in a workspace that already resolved, for the address they typed. An unauthenticated caller cannot cause anything here they could not cause by waiting for tonight's sweep. Every other case — no workspace, unknown slug, no such invited identity, a sealed market, a tenant whose `TenantCache` names no market — is a recorded refusal and changes nothing about the response.\n\n**`SETUP` is completed by the existing recovery rail, not by a new endpoint** (ADR 0068 D5): `xc.auth.mobile.password.forgot` then `xc.auth.mobile.password.reset.verify`, then `xc.auth.mobile.password.reset`, then `xc.auth.login`. Those three are realm relays, not mobile-specific, and are reused as-is so there is exactly one password-mutation path in the product.\n\n**No rate limit exists on this route**, deliberately and on the record: neither `xc.auth.login` nor `xc.auth.mobile.password.forgot` has a counter either, so throttling only this one would be a control on the cheapest of three equivalent doors. Registered as `SGAP-44` (`security-docs/06`) to be fixed across the surface at once.\n",
        "tags": [
          "xc",
          "auth"
        ],
        "x-token": "xc.auth.identify",
        "x-realizes-features": [
          "XC-F02",
          "XC-F03"
        ],
        "x-screens": [
          "XC-S02",
          "XC-S06"
        ],
        "x-touches-entities": [
          "xc.identities",
          "xc.tenant_cache"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AuthIdentifyInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The next step. `200` on every answer, including the one for an address nothing is known about: a distinct status would hand an unauthenticated prober a countable signal, and a routing decision is not a failure. Nothing is minted and no cookie is set.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthIdentifyResult"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "description": "An identity provider is unreachable, or answered in a way nothing can be concluded from. Not collapsed into a `PASSWORD` default: that would silently route every employee of a down region to a password box their password does not open, and hide the outage from operators. Carries `Retry-After`, in the same shape `xc.auth.login` uses.\n",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/auth/login": {
      "post": {
        "operationId": "xc.auth.login",
        "summary": "Sign in to the web back-office with a password",
        "description": "The portal password rail (fsd 02 `XC-S02`/`XC-S06`) — the third mint path, alongside the bridge-token exchange on Surface 2 (`platform.sso.exchange`, 08 §4). Fixed order: verify the credential at the identity store that owns it → apply the **shared** subscription-status gate (`evaluateSessionMint`, 08 §4 — one decision, not two hand-rolled checks) → mint the `xc.sessions` row → project the principal.\n\n**WHO VERIFIES THE PASSWORD DEPENDS ON THE PERSONA, AND IS NEVER A REQUEST FIELD** (ADR 0063, issue #347; portal contract `saas-portal/docs/59`). A **back-office** credential belongs to Sysmedac One and is verified service-to-service (`POST /api/v1/platform/accounts/verify-credential`), with the workspace candidates read from `GET /api/v1/platform/accounts/{accountId}/workspaces` — GroundIT stores no back-office password and keeps no attempt counter, so One's per-email throttle is authoritative and surfaces here as `429` with `Retry-After`. An **ESS employee** (ADR 0039/0059) is verified at their market's **Keycloak** realm exactly as on mobile, because employees are not platform customers. **The realms are asked FIRST** (revised 2026-08-23), so an employee's password is never transmitted to One: the delegated arm is reached only for an address no provisioned realm affirms. In the other direction One's uniform `401` means \"not mine\" and leaves Keycloak's refusal standing. The delegated arm is **config-gated** and is sealed until the portal deploys, in which case every sign-in here is Keycloak-verified as it has been since #324. Nothing in the request or response shape changes either way, and a client cannot tell which store answered.\n\n**The workspace is chosen after the credential verifies** (ADR 0022). The `sub` is the identity anchor, not the `tenantUid` claim, so one credential may hold identities in several workspaces: the verified subject resolves to a candidate set (`app.lookup_workspaces_by_subject`, migration 0057) and `workspace` selects within it. Present — the slug `/{workspace}/login` was served under — it MUST be among the candidates, otherwise valid credentials for workspace A would open a session on workspace B's URL. Absent — the unbranded `/login` entry, which has no slug to bind — one candidate signs straight in and more than one answers `SELECT_WORKSPACE`.\n\n**An `invitation_token` short-circuits that resolution and redeems the invitation** (issue #348, ADR 0061). It is the only way a `sub` is ever bound into a tenant its own token does not name: the credential verifies at the IdP as usual, then the single-use selector/verifier from the emailed link is spent and the tenant is read from the invitation row — never from the request, so the candidate set is bypassed and any `workspace` sent alongside is ignored. From the next sign-in onward that workspace is a candidate through the ordinary `sub` arm, which is how the ADR 0022 picker finally lights up for a cross-tenant identity. A spent, expired, revoked or unknown invitation is the same uniform `401` as a bad password.\n\n`market` is optional. Given, it names the realm that authenticates (`groundit-in` / `groundit-sa`). Omitted, every provisioned realm is attempted **in parallel** and each success contributes its own subject's candidates — parallel rather than serial so response time does not disclose which region the account belongs to. It names a **Keycloak realm**, so it is **ignored** on the delegated back-office arm (ADR 0063): Sysmedac One has no realms, and the field is ignored rather than rejected so a stale form value never becomes a sign-in failure.\n\n**Set-Cookie is the entire client credential.** No token, refresh token, or Keycloak material is returned in the body; a `SIGNED_IN` response carries the opaque `sessionCookie` handle plus the same projection `GET /auth/session` serves, so the shell renders without a second round trip. Failures are uniform on purpose — a bad password, an unknown email, and a `workspace` outside the candidate set all answer `401`, disclosing nothing about which one it was, and in particular never confirming that the account exists but belongs elsewhere.\n\n**Which principal signs in is a property of the identity, not of this route** (ADR 0039, issue #665). An identity carrying a platform-member binding projects `principalClass: WORKSPACE_MEMBER`; one without — an employee nobody put in the workspace member list — projects `EMPLOYEE` on this same cookie mechanism and resolves EMPLOYEE-subject grants (the ESS portal). The class is derived server-side from the mint channel and that binding, and re-derived identically on every subsequent request, so no request field can influence which principal a session carries.\n",
        "tags": [
          "xc",
          "auth"
        ],
        "x-token": "xc.auth.login",
        "x-realizes-features": [
          "XC-F02",
          "XC-F03"
        ],
        "x-screens": [
          "XC-S02",
          "XC-S06"
        ],
        "x-touches-entities": [
          "xc.identities",
          "xc.sessions",
          "xc.tenant_cache",
          "xc.workspace_invitations",
          "admin.member_grants",
          "admin.tenant_config"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LoginInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Three outcomes, discriminated on `result`. **`SIGNED_IN`** — the session cookie is set and the body carries the session projection. **`SELECT_WORKSPACE`** — the credential verified but resolved to more than one workspace and the request named none. **`CHOOSE_PORTAL`** — the workspace resolved, but the identity holds both a member binding and a linked employee and the request named no `portal` (#802). On both questions **no cookie is set and no session is minted**, and the caller re-posts the same credentials plus its answer. All three are `200` because a selection is not a failure; a distinct status would hand an unauthenticated prober a countable signal — that an address is employed more than once, or that it holds a privileged second hat.\n",
            "headers": {
              "Set-Cookie": {
                "description": "`<name>=<opaque handle>; Path=/; HttpOnly; SameSite=Lax; Max-Age=<session TTL>` — plus `Secure` off localhost. Browsers attach it automatically; scripts cannot read it. Sent on `result: SIGNED_IN` only.\n",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/LoginResult"
                    },
                    {
                      "$ref": "#/components/schemas/WorkspaceSelectionRequired"
                    },
                    {
                      "$ref": "#/components/schemas/PortalSelectionRequired"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "result",
                    "mapping": {
                      "SIGNED_IN": "#/components/schemas/LoginResult",
                      "SELECT_WORKSPACE": "#/components/schemas/WorkspaceSelectionRequired",
                      "CHOOSE_PORTAL": "#/components/schemas/PortalSelectionRequired"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Sign-in refused on tenant status — `code` = TENANT_SUSPENDED, either the workspace's own `SUSPENDED` status or the platform's org-wide `masterStatus` kill switch (08 §4). Reversible: a resume restores access. A `PAST_DUE` workspace still signs in and is blocked per-write with `423` instead.\n",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "410": {
            "description": "Terminal — `code` = TENANT_CANCELLED (08 §4). Also returned when no `xc.tenant_cache` row exists at all: the platform never told us this tenant is live, so the mint fails closed.\n",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "description": "An identity provider is unreachable — the named Keycloak realm, or (when `market` is omitted) a realm that failed to answer while no other realm authenticated, or **Sysmedac One** on the delegated back-office arm (unreachable, a 5xx, a rejected service token, or an answer naming a different email address than was submitted — ADR 0063). An IdP outage is deliberately NOT collapsed into the uniform `401`: a flood of \"invalid email or password\" would hide infrastructure trouble from operators and lie to users. Carries `Retry-After`.\n",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying the sign-in.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/auth/logout": {
      "post": {
        "operationId": "xc.auth.logout",
        "summary": "Sign out of the web back-office",
        "description": "Revokes the `xc.sessions` row the cookie names (`status → 'REVOKED'`, db 14 §1) and clears the cookie. The browser-cookie counterpart of `xc.session.logout`, which is the mobile/bearer surface's own logout — both exist because both rails do; tokens are append-only, so the `/auth/*` pair takes its own id rather than renaming that one.\n\n**Signing out is never gated on subscription status.** The entitlement layer would otherwise `403` a suspended workspace's request before the cookie cleared, trapping a browser in a session it cannot shed; this route skips that gate deliberately (authN still resolves). For the same reason an unknown or already-revoked handle still answers `204` and still clears the cookie — a logout that left the browser believing it held a live session would be the worse failure.\n",
        "tags": [
          "xc",
          "auth"
        ],
        "x-token": "xc.auth.logout",
        "x-realizes-features": [
          "XC-F03"
        ],
        "x-screens": [
          "XC-S06",
          "XC-S10"
        ],
        "x-touches-entities": [
          "xc.sessions"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "security": [
          {
            "sessionCookie": []
          },
          {}
        ],
        "responses": {
          "204": {
            "description": "Signed out; the cookie is expired. Returned for an unknown handle too.",
            "headers": {
              "Set-Cookie": {
                "description": "`<name>=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0` — expires the session cookie.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/auth/switch-principal": {
      "post": {
        "operationId": "xc.auth.switch_principal",
        "summary": "Move to the other portal (verified session re-mint)",
        "description": "The in-app portal switch for a dual-hat human — an admin who also draws pay (issue #802, the ADR 0039 amendment). **A re-mint, not a mode toggle**: the presented session is retired and a fresh cookie is minted carrying the other principal class, then the caller lands on `/me` (ESS) or `/home` (back office).\n\n**Why a re-mint.** One principal per session is the invariant the whole authorization stack keys on — `AuthorizationEngine` joins `admin.member_grants` on the session's class, and RLS stamps its scope GUCs from what that resolved. A \"current mode\" flag would mean one session whose class changes underneath those joins: blended authority, and an audit trail that cannot say which hat performed a write. Retiring and re-minting leaves the authorization engine, the grant partition (ADR 0024 §(d)) and the RLS scope stamps **completely unchanged**.\n\n**The link is verified server-side, twice.** `to` names a destination and nothing else. Both personas must exist on the calling identity — `platform_member_id` for management, an ACTIVE `people.employee_subjects` row of kind `KEYCLOAK_SUB` for ESS — and that check is re-run inside the same transaction that writes, closing the window between \"we offered the switch\" and \"we minted it\". The employee half is keyed on `KEYCLOAK_SUB` and nothing weaker, because that is the only key `AuthorizationEngine` will accept for an `EMPLOYEE` session; offering a portal on other evidence would mint a session that then resolves to no grants at all.\n\n**The old cookie is ALWAYS retired.** The revoke and the insert share one transaction, so the only reachable outcomes are (old dead, new live) and (old live, nothing minted). Two live sessions for one human on one browser, or a blended one, are unrepresentable.\n\n**Step-up (security-docs/05 §5.2).** ESS→Management crosses into the plane that reaches payroll, statutory identifiers and the RBAC matrix, so it is the guarded direction; Management→ESS is a step *down* and is not challenged. GroundIT holds no second-factor verifier of its own today — §5.2 settles MFA as a **Keycloak realm required-action**, rolled out by #643 — so the gate is written against the two seams that exist and bites the moment either becomes real: an identity that IS enrolled (`xc.identities.mfa_enabled`) and presents no fresh challenge is REFUSED now, and stale or malformed evidence is refused regardless of enrolment. A switch that proceeds un-challenged because no factor is enrolled writes an `MFA_CHALLENGE` row with `outcome: ALLOW` and `resource: ess-to-management:step-up-unenforceable`, so the exposure is countable from the audit plane — distinct from an actual refusal — rather than inferred from source. Registered as `SGAP-29` (security-docs/06).\n\n**Both directions are audited** to `audit.access_log` in the same transaction as the switch: `LOGIN`/`ALLOW` for the re-mint itself; an `MFA_CHALLENGE` row for the guardrail whose `outcome` distinguishes what happened — `ALLOW` when the switch proceeded (challenge satisfied, or unenforceable), `DENY` only on an actual refusal. A query for `event = MFA_CHALLENGE AND outcome = DENY` therefore returns real step-up refusals alone.\n\nWeb cookie rail only — a `MOBILE` session is refused, because ADR 0021 pins `MOBILE ⇒ EMPLOYEE`. Switching to the class already held is idempotent, not an error: a double-clicked menu item must not retire a good session and answer `4xx`.\n",
        "tags": [
          "xc",
          "auth"
        ],
        "x-token": "xc.auth.switch_principal",
        "x-realizes-features": [
          "XC-F03"
        ],
        "x-screens": [
          "XC-S06"
        ],
        "x-touches-entities": [
          "xc.sessions",
          "xc.identities",
          "people.employee_subjects",
          "xc.tenant_cache",
          "audit.access_log"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "security": [
          {
            "sessionCookie": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SwitchPrincipalInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Switched. The cookie now carries a NEW opaque handle and the one presented is revoked — a client that fails to adopt the `Set-Cookie` is holding a dead session, not a stale one.\n",
            "headers": {
              "Set-Cookie": {
                "description": "`<name>=<opaque handle>; Path=/; HttpOnly; SameSite=Lax; Max-Age=<session TTL>` — plus `Secure` off localhost. A DIFFERENT handle from the one presented, always.\n",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SwitchPrincipalResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`code` = SCOPE_DENIED. Either this identity holds no verified link to a second portal, or the ESS→Management step-up was not satisfied — `detail` distinguishes them for the user, and both are written to `audit.access_log` as a `DENY`. The shared code is deliberate: it is the same problem the PII-reveal step-up raises, and one step-up failure shape across the product beats a second code that means the same thing.\n",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/auth/session": {
      "get": {
        "operationId": "xc.auth.get_session",
        "summary": "The current session projection (who am I · what may I do)",
        "description": "The **one** authenticated read the web back-office renders itself from (api-docs 02 §6, ADR 0010 §c). Not a token payload — the session token stays identity-only — but a per-request server projection over `xc.identities` + `admin.member_grants` + `xc.tenant_cache`, so a revoked grant, a support impersonation, or a subscription lapse changes what the portal shows on the very next render, at zero token-TTL latency.\n\nTwo fields exist purely to satisfy ADR 0009 §f's platform UI obligations. `is_impersonation` is true for a session minted by `platform.tenant.start_impersonation` and raises the unmissable \"Sysmedac support is viewing your workspace\" banner — only the **boolean** crosses the wire, the acting super-admin identity stays in the audit plane. `workspace.status` mirrors the same `xc.tenant_cache.status` the entitlement guard enforces on, so the shell can render the `PAST_DUE` read-only banner *before* a write earns its `423`. It is a **mirror, not a gate**: enforcement stays server-side, and a missing cache row projects as `SUSPENDED` so the projection can never be the optimistic reader that contradicts a fail-closed guard.\n\nDeclares no permission token: \"what am I allowed to do\" must be answerable by any signed-in principal, whatever their grants.\n\n**Cookie only, today.** The authentication middleware that fills this projection's request context resolves the `sessionCookie` handle and nothing else, so `bearerJWT` is deliberately NOT listed: the mobile rail carries its identity on a different seam and does not render this shell. Listing a scheme the build does not accept here would be a spec that lies.\n",
        "tags": [
          "xc",
          "auth"
        ],
        "x-token": "xc.auth.get_session",
        "x-realizes-features": [
          "XC-F03",
          "XC-F04"
        ],
        "x-screens": [
          "XC-S06",
          "XC-S07"
        ],
        "x-touches-entities": [
          "xc.identities",
          "xc.sessions",
          "xc.tenant_cache",
          "admin.member_grants",
          "admin.roles",
          "admin.permissions",
          "people.employee_subjects"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "security": [
          {
            "sessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "The caller's session projection.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SessionProjection"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/auth/mobile/login": {
      "post": {
        "operationId": "xc.auth.mobile_login",
        "summary": "Sign in from the mobile app (returns a bearer token)",
        "description": "The Flutter app's sign-in (`XC-S02`, design-docs/05 `AB-01`, issue #275). Fixed order, identical to the portal rail: verify the credential at the Keycloak realm → resolve the verified subject's candidate workspaces → apply the shared subscription-status gate (`evaluateSessionMint`) on the chosen one → mint the `xc.sessions` row with `channel = 'MOBILE'` → project the principal.\n\n**The token is the entire credential.** It is the same opaque `<tenantUid>.<tokenRef>` handle the portal keeps in a cookie, returned in the body instead of a `Set-Cookie`. The app MUST treat it as a bytestring — never parse, split, or decode it — store it in platform secure storage (iOS Keychain / Android Keystore-backed storage), keep it out of logs and crash reports, and send it as `Authorization: Bearer <token>` on every subsequent call. It is not a JWT and carries no claims: the session's meaning lives in the database row, which is what makes revocation immediate.\n\n**The app never asks who the user works for; it asks after the password is right** (ADR 0022). A handset has no `/{workspace}/…` URL to read a slug from, and asking an employee to recall their employer's slug is a support ticket on day one — so the workspace is resolved from the **verified `sub`** (`app.lookup_workspaces_by_subject`, migration 0057) rather than from anything the caller typed. One candidate signs straight in; more than one returns `SELECT_WORKSPACE` and the app re-posts the same credentials with the chosen `workspace`. A `workspace` that is not among the candidates answers the same uniform `401` as a bad password, so a caller cannot learn that the account exists but belongs elsewhere. `market` is optional — omitted, every provisioned realm is attempted in parallel, so response time does not disclose the account's region.\n\n**No refresh token.** When `expiresAt` passes — or any call answers `401` — the app signs in again.\n",
        "tags": [
          "xc",
          "auth"
        ],
        "x-token": "xc.auth.mobile_login",
        "x-realizes-features": [
          "XC-F02",
          "XC-F03"
        ],
        "x-screens": [
          "XC-S02",
          "XC-S03",
          "XC-S04"
        ],
        "x-touches-entities": [
          "xc.identities",
          "xc.sessions",
          "xc.tenant_cache",
          "admin.tenant_config"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MobileLoginInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Two outcomes, discriminated on `result` — the same shape the portal rail returns. **`SIGNED_IN`** — the body carries the bearer token and the session projection. **`SELECT_WORKSPACE`** — the credential verified but resolved to more than one workspace and the request named none: **no token and no session are minted**, and the app re-posts the same credentials plus the chosen `workspace`. Deliberately stateless: there is no selection ticket to store, expire, or leak. The app MUST branch on `result` before reading `token`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/MobileLoginResult"
                    },
                    {
                      "$ref": "#/components/schemas/WorkspaceSelectionRequired"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "result",
                    "mapping": {
                      "SIGNED_IN": "#/components/schemas/MobileLoginResult",
                      "SELECT_WORKSPACE": "#/components/schemas/WorkspaceSelectionRequired"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Sign-in refused on tenant status — `code` = TENANT_SUSPENDED. Same shared gate as the portal rail (08 §4), evaluated on the CHOSEN workspace; a `PAST_DUE` workspace still signs in and is blocked per-write with `423`.\n",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "410": {
            "description": "Terminal — `code` = TENANT_CANCELLED, or no `xc.tenant_cache` row exists at all (fails closed).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "description": "A Keycloak realm is unreachable and no realm authenticated — an IdP outage, deliberately not collapsed into `401`. Carries `Retry-After`.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying the sign-in.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/auth/mobile/login/otp/request": {
      "post": {
        "operationId": "xc.auth.mobile_otp_request",
        "summary": "Request a passwordless mobile sign-in code",
        "description": "Starts `XC-S02`/`XC-S03`. The API normalizes the email or E.164 mobile number and asks every provisioned Keycloak realm in parallel when `market` is absent. Keycloak owns code generation, delivery, expiry, resend and attempt state. GroundIT receives and returns only an opaque challenge handle. Known and unknown identifiers both return the same `202` shape; Keycloak supplies a decoy challenge for an unknown identity, preventing account and market enumeration.\n",
        "tags": [
          "xc",
          "auth"
        ],
        "x-token": "xc.auth.mobile_otp_request",
        "x-realizes-features": [
          "XC-F03",
          "XC-F05"
        ],
        "x-screens": [
          "XC-S02",
          "XC-S03"
        ],
        "x-touches-entities": [
          "xc.identities"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MobileOtpRequestInput"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Generic challenge response, whether or not an account exists.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MobileOtpChallenge"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "description": "Identity provider temporarily unavailable. Retry after the `Retry-After` delay.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds before retrying."
              }
            },
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/auth/mobile/login/otp/verify": {
      "post": {
        "operationId": "xc.auth.mobile_otp_verify",
        "summary": "Verify a mobile sign-in code and mint a bearer session",
        "description": "Keycloak verifies the six-digit code against its opaque challenge. A successful assertion enters the exact workspace-selection and session-mint path used by password login, with `channel=MOBILE`, `auth_method=OTP`. The result is `SIGNED_IN` or `SELECT_WORKSPACE`; on the latter, submit the returned one-use `selectionToken` with the chosen workspace before it expires. The OTP is consumed once and is never re-submitted.\n",
        "tags": [
          "xc",
          "auth"
        ],
        "x-token": "xc.auth.mobile_otp_verify",
        "x-realizes-features": [
          "XC-F03"
        ],
        "x-screens": [
          "XC-S03"
        ],
        "x-touches-entities": [
          "xc.identities",
          "xc.sessions",
          "xc.tenant_cache"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MobileOtpVerifyInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Signed in, or workspace selection required; same discriminated response as password login.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/MobileLoginResult"
                    },
                    {
                      "$ref": "#/components/schemas/WorkspaceSelectionRequired"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "result"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "410": {
            "$ref": "#/components/responses/Gone"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "description": "Identity provider temporarily unavailable. Retry after the `Retry-After` delay.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds before retrying."
              }
            },
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/auth/mobile/password/forgot": {
      "post": {
        "operationId": "xc.auth.mobile_password_forgot",
        "summary": "Request a password-recovery code",
        "description": "Starts `XC-S05`. The public response is deliberately identical for known and unknown identifiers. Keycloak owns and delivers the OTP; GroundIT stores no recovery state or code.\n",
        "tags": [
          "xc",
          "auth"
        ],
        "x-token": "xc.auth.mobile_password_forgot",
        "x-realizes-features": [
          "XC-F03",
          "XC-F05"
        ],
        "x-screens": [
          "XC-S05"
        ],
        "x-touches-entities": [
          "xc.identities"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MobileOtpRequestInput"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Generic recovery challenge response.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MobileOtpChallenge"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "description": "Identity provider temporarily unavailable. Retry after the `Retry-After` delay.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds before retrying."
              }
            },
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/auth/mobile/password/reset/verify": {
      "post": {
        "operationId": "xc.auth.mobile_password_reset_verify",
        "summary": "Verify a password-recovery code",
        "description": "Exchanges the OTP challenge and six-digit code for a short-lived, one-use opaque reset handle. This does not mint a GroundIT session and the handle cannot authenticate API calls.\n",
        "tags": [
          "xc",
          "auth"
        ],
        "x-token": "xc.auth.mobile_password_reset_verify",
        "x-realizes-features": [
          "XC-F03"
        ],
        "x-screens": [
          "XC-S05"
        ],
        "x-touches-entities": [
          "xc.identities"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MobilePasswordResetVerifyInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Verified one-use reset handle.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MobilePasswordResetChallenge"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "description": "Identity provider temporarily unavailable. Retry after the `Retry-After` delay.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds before retrying."
              }
            },
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/auth/mobile/password/reset": {
      "post": {
        "operationId": "xc.auth.mobile_password_reset",
        "summary": "Set a new password using a verified reset handle",
        "description": "Consumes the one-use reset handle in Keycloak. Password policy, hashing and mutation remain entirely IdP-owned; GroundIT neither stores nor logs either password. A policy rejection is `422`.\n\n**Revokes every credential the principal holds** in each bound workspace: all opaque sessions (whatever their status) **and** every ADR 0057 refresh family (issue #961). A client holding a refresh credential must discard it — a password reset is the user saying \"cut off whoever has my account\", so a stolen 30-day credential cannot outlive it.\n",
        "tags": [
          "xc",
          "auth"
        ],
        "x-token": "xc.auth.mobile_password_reset",
        "x-realizes-features": [
          "XC-F03",
          "XC-F06"
        ],
        "x-screens": [
          "XC-S05"
        ],
        "x-touches-entities": [
          "xc.identities",
          "xc.sessions",
          "xc.refresh_tokens"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MobilePasswordResetInput"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Password updated; return to the login screen."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "description": "Identity provider temporarily unavailable. Retry after the `Retry-After` delay.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds before retrying."
              }
            },
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/auth/mobile/refresh": {
      "post": {
        "operationId": "xc.auth.mobile_refresh",
        "summary": "Exchange a refresh credential for a fresh session (rotating, MOBILE only)",
        "description": "The ADR 0057 refresh rail (`GAP-33`, issue #961). Exchanges the refresh credential minted by a `rememberDevice: true` sign-in for a **fresh bearer session AND a fresh refresh credential**, in one transaction. Without it an installed app forces a full OTP/password login every day, on the surface whose whole promise is punch-in-three-seconds.\n\n**The refresh token travels in the BODY, never in `Authorization`.** It is not a session handle and authenticates nothing if presented as one. No bearer token is required or read here, which is the point: this operation works on a cold start with nothing in memory, and on any `401` after the handle has already lapsed.\n\n**Rotation is mandatory and single-use.** Every successful call spends the presented token, returns a NEW one in the same rotation family, and REVOKES the session the previous credential minted — so the client must persist `refreshToken` and `token` together, atomically, and must not retry a call that may have succeeded. Two simultaneous refreshes of the same token cannot both win: the loser is treated as a replay.\n\n**Replaying a spent token revokes the entire family and every session it minted.** A row can be spent once, so a second presentation proves two holders exist and the server cannot tell which is legitimate — it trusts neither. One sign-in = one family = one handset, so that kill never reaches the user's other devices. The answer is still the uniform `401`; the audit plane records the event.\n\n**`expiresAt` slides.** Each rotation issues a new refresh expiry `AUTH_REFRESH_TTL_SECONDS` out (default 2 592 000 = 30 days), so an app in daily use never lapses and one abandoned for a month does. The access handle keeps its own, much shorter TTL — the two numbers answer different questions.\n\n**Guard posture.** Like `mobile_login`/`mobile_logout` this carries an `operationId` for traceability but is **not a grantable permission** (the `SGAP-10` shape, security-docs/06): at the moment it runs there is no principal, because producing one is what it is for. What binds is possession of an unguessable 256-bit secret, plus a re-read — inside the rotation — of the identity's status, the workspace's subscription status, and whether the session this lineage last minted has been revoked. That last check is the choke point ADR 0056 §(c) leans on: every existing revocation path kills the lineage transitively, without knowing this rail exists.\n\n**Rate limiting is deliberately not a per-IP quota.** The credential is 32 bytes of CSPRNG output, so there is nothing to enumerate, while a plant's workforce shares one egress — a per-IP bucket would take the whole shed offline at shift change. Abuse is bounded structurally instead: a family that is replayed dies on the first replay. Edge/ingress limits still apply, hence `429`.\n",
        "tags": [
          "xc",
          "auth"
        ],
        "x-token": "xc.auth.mobile_refresh",
        "x-realizes-features": [
          "XC-F03"
        ],
        "x-screens": [
          "XC-S02",
          "XC-S03"
        ],
        "x-touches-entities": [
          "xc.refresh_tokens",
          "xc.sessions",
          "xc.identities",
          "xc.tenant_cache"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MobileRefreshInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Rotated. Carries the same members a `SIGNED_IN` login does, with `refreshToken` and `refreshExpiresAt` REQUIRED — a rotation that returned no successor credential would strand the device at the next expiry. There is no `SELECT_WORKSPACE` branch: the workspace was settled when the family was opened.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MobileRefreshResult"
                }
              }
            }
          },
          "401": {
            "description": "The uniform answer to **all five** failure classes — malformed token, unknown token, expired token, revoked token, and a replayed (already-spent) token — so possession cannot be probed by reading the response. Bare problem+json with no `code`: which check failed is not disclosed. The replay case additionally revokes the family as a side effect.\n",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Refused on tenant status — `code` = TENANT_SUSPENDED. A rotation IS a session mint, so it applies the same `evaluateSessionMint` gate as sign-in and cannot become the one door that stays open into a suspended workspace. Evaluated only AFTER the credential verifies, so an unauthenticated prober learns nothing about a workspace's billing state.\n",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "410": {
            "description": "Terminal — `code` = TENANT_CANCELLED, or no `xc.tenant_cache` row exists at all (fails closed).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/auth/mobile/logout": {
      "post": {
        "operationId": "xc.auth.mobile_logout",
        "summary": "Sign out of the mobile app",
        "description": "Revokes exactly the session the presented bearer token names — not \"the caller's sessions\". An app signing out on one handset must not knock its owner off another.\n\nIdempotent and uniformly `204`: an unknown, expired, or malformed handle is a silent success, so an unauthenticated caller cannot probe which handles are live. Subscription gating is skipped deliberately (the same reason as `xc.auth.logout`) — a workspace's billing state must never trap an app in a session it cannot shed.\n\n**Also revokes the ADR 0057 refresh family that session belongs to** (issue #961). Signing out must not leave a 30-day credential alive on a handset the user just walked away from, and killing the FAMILY rather than only the newest row is what stops a token captured earlier in the lineage from resurrecting the device. A client holding a refresh credential should discard it here.\n",
        "tags": [
          "xc",
          "auth"
        ],
        "x-token": "xc.auth.mobile_logout",
        "x-realizes-features": [
          "XC-F03"
        ],
        "x-screens": [
          "XC-S10"
        ],
        "x-touches-entities": [
          "xc.sessions",
          "xc.refresh_tokens"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "responses": {
          "204": {
            "description": "Signed out. Always returned, whether or not the handle named a live session."
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/workspaces/{slug}": {
      "get": {
        "operationId": "xc.workspace.resolve",
        "summary": "Resolve a workspace slug for the sign-in page",
        "description": "The **one pre-authentication read** in the product surface. Path-based tenancy puts the workspace in the URL (`/{workspace}/login`), so the sign-in page must resolve the slug — to brand itself and to bind the attempt (`LoginInput.workspace`) — before anyone has signed in.\n\n**Disclosure posture.** An anonymous lookup keyed by a guessable string is an enumeration surface, so it returns the minimum a login page can render and nothing more: display name and brand colour. No member counts, no plan, no ids, and no status detail. An unknown slug and a **cancelled** workspace are the SAME `404`, so probing cannot distinguish \"never existed\" from \"closed account\" (02 §4). Malformed slugs are rejected before they reach the database.\n\n`tenantUid` is not a parameter and is never returned — this operation is how the tenant gets established, not a way to name one.\n",
        "tags": [
          "xc",
          "workspace"
        ],
        "x-token": "xc.workspace.resolve",
        "x-realizes-features": [
          "XC-F02",
          "XC-F03"
        ],
        "x-screens": [
          "XC-S02",
          "XC-S06"
        ],
        "x-touches-entities": [
          "xc.tenant_cache",
          "admin.tenant_config"
        ],
        "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": [],
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The platform's `workspace_slug` — lowercase kebab, 2–63 characters.",
            "schema": {
              "type": "string",
              "pattern": "^[a-z0-9][a-z0-9-]{1,62}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "What a login page may know about a workspace before anyone authenticates.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicWorkspace"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/sessions/logout": {
      "post": {
        "operationId": "xc.session.logout",
        "summary": "End the caller's current session",
        "description": "The Profile-tab **Log out** action (fsd 02 XC-S10) — `xc.sessions.status → 'REVOKED'` (db 14 §1), a security event kept distinct from natural expiry. Unversioned, low-contention entity — no `If-Match`.\n",
        "tags": [
          "xc",
          "session"
        ],
        "x-token": "xc.session.logout",
        "x-realizes-features": [
          "XC-F03"
        ],
        "x-screens": [
          "XC-S10"
        ],
        "x-touches-entities": [
          "xc.sessions"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": "xc.session.revoked",
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "Session revoked.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SessionLogoutResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/events/stream": {
      "get": {
        "operationId": "xc.event_stream.subscribe",
        "summary": "Open the portal SSE live-update stream",
        "description": "Web-only server→client live-update channel (ADR 0016), one-way and fed only from the `xc.outbox` drain. Without `topic`, this retains the recipient-scoped `xc.sse_events` behaviour: each frame is `event: <event_name>` (e.g. `approval.pending`, `dashboard.refresh` — db 14 §3 examples), `data: <json delta>` (the resolved, already-localized projection payload — `xc.sse_events.data`), `id: <xc.sse_events.id>` (a UUIDv7, monotonically increasing per stream. With a supported `topic`, the API re-checks that topic's read token and resource under the authenticated tenant, then holds open a bounded Redis Stream cursor shared by all watchers of that topic. Topic frames use `event: invalidate`, a compact PII-free outbox identity envelope as data, and the Redis stream id as `id`. Heartbeats run every 15 seconds; slow response sockets are dropped. `Last-Event-ID` resumes either mode in its native cursor format. A long gap may outlive bounded retention, so topic clients re-fetch on connection and focus. Mobile receives the same facts via FCM instead.\n",
        "tags": [
          "xc",
          "event-stream"
        ],
        "x-token": "xc.event_stream.subscribe",
        "x-realizes-features": [
          "XC-F05"
        ],
        "x-screens": [
          "XC-S07",
          "XC-S13",
          "XC-S15"
        ],
        "x-touches-entities": [
          "xc.sse_events"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": true,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/CorrelationId"
          },
          {
            "name": "topic",
            "in": "query",
            "required": false,
            "description": "Shared-resource fan-out topic. Closed set: `work.board:{boardId}`, `recruit.pipeline:all`, or `recruit.pipeline:{openingId}`. The server maps it to required read permissions and the authenticated tenant; no client-supplied token or tenant is accepted.\n",
            "schema": {
              "type": "string",
              "pattern": "^(work\\.board:[0-9a-fA-F-]{36}|recruit\\.pipeline:(all|[0-9a-fA-F-]{36}))$"
            }
          },
          {
            "name": "Last-Event-ID",
            "in": "header",
            "required": false,
            "description": "Resume cursor. UUIDv7 `xc.sse_events.id` in recipient mode; Redis `<milliseconds>-<sequence>` id in topic mode.\n",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The live SSE stream (`text/event-stream`). The connection is held open; frames are pushed as the caller's `recipient_identity_id`-scoped events are dispatched. The client is expected to reconnect with `Last-Event-ID` on drop.\n",
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string",
                  "description": "SSE wire format: repeating `id: …\\nevent: …\\ndata: …\\n\\n` frames, per the envelope described above."
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/files/upload-url": {
      "post": {
        "operationId": "xc.file.request_upload_url",
        "summary": "Mint a presigned object-storage upload target",
        "description": "The generic \"prior presigned-upload flow (XC-F07)\" every module's own document/attachment op cites as its precondition (db 14 §4 `xc.object_refs`; e.g. `docs.document.upload`, `recruit.candidate.*` résumé/attachment ops, `perform.certification.*` evidence uploads, `people.employee_profile.update` photo) — mints a time-limited presigned PUT target and the `storage_key` the owning module's own create/update op then references. **Bytes never transit this API.** No `xc.object_refs` row is persisted at this step (the owning module's op registers ownership, `owner_type`/`owner_id`, when it accepts the upload) — an ephemeral mint, not a resource create, hence `200` not `201`.\n\n**Any principal holding the token may mint, including `SELF`-scoped `EMPLOYEE` principals on the\nmobile rail.** `x-rls-scope: tenant` here means \"no row confinement\", NOT \"requires a tenant-wide grant\" ([`02 §4.1`](../../02-auth-tenancy-scopes.md)) — the role matrix grants this token to `employee`, and the handler gates on `ANY`. There is nothing to confine because the returned `storage_key` is built **entirely server-side** as `<tenantUid>/uploads/<uuid>/<doc_type>/<uuid><ext>`: `tenantUid` comes from the verified session, both identifiers are freshly generated per call, and `file_name` contributes only its validated extension. A caller can therefore never name, overwrite or read another principal's object — the presign is PUT-only at an unguessable key inside its own tenant prefix, and reads go through `xc.file.get`.\n",
        "tags": [
          "xc",
          "file"
        ],
        "x-token": "xc.file.request_upload_url",
        "x-realizes-features": [
          "XC-F07"
        ],
        "x-screens": [
          "DOC-S07"
        ],
        "x-touches-entities": [
          "xc.object_refs"
        ],
        "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": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FileUploadUrlRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Presigned upload target.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FileUploadUrlResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "423": {
            "$ref": "#/components/responses/Locked"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/files/{id}": {
      "get": {
        "operationId": "xc.file.get",
        "summary": "Get a file's metadata and a fresh presigned download",
        "description": "Generic download resolution for a bare `xc.object_refs` id (db 14 §4) — most module surfaces embed their own resolved `FileDownload` directly on read (e.g. `docs.document.get`) and never call this op; it exists for the cases that only hold the raw object-storage reference (e.g. a `decision_artifacts` evidence pointer, an approval-inbox attachment). `download.url` is a freshly minted, time-limited presigned URL, never persisted (`XC-F07`, db 00 §14). Authorization is scope-gated: a caller with a tenant-wide grant may resolve any tenant file, while a non-tenant-wide caller may resolve only an object reference whose `uploaded_by` is that caller. HR-issued records continue to use their module-specific download operations rather than this generic route.\n",
        "tags": [
          "xc",
          "file"
        ],
        "x-token": "xc.file.get",
        "x-realizes-features": [
          "XC-F07"
        ],
        "x-screens": [
          "DOC-S07"
        ],
        "x-touches-entities": [
          "xc.object_refs"
        ],
        "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"
          }
        ],
        "responses": {
          "200": {
            "description": "File metadata plus a fresh presigned download.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/File"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/dashboard-projections": {
      "get": {
        "operationId": "xc.dashboard_projection.list",
        "summary": "Read the caller's role-aware home tiles",
        "description": "Event-built per-principal (or role/tenant-scoped) dashboard tiles the Home/Work/Profile tabs render directly — **no cross-schema joins**, rebuilt from `xc.outbox` (fsd 02 XC-S07 check-in hero/quick actions/leave-balance/up-next cards, XC-S08 tab composition; db 14 §5 `xc.dashboard_projections`, XC-F09). Eventually consistent (bounded staleness, db 00 §13); read-your-writes flows read the owning module directly instead. Sort whitelist: none (natural `scope`/`projection_key` grouping).\n\n**`DIVISION` rows are excluded unconditionally (#433).** `xc.dashboard_projections` also holds org-unit facts for the division console (ADM-S08), and every one of them carries a NULL `subject_identity_id` because a division fact has no principal — the same shape this SELF-scoped read admits for tenant/role tiles. They are filtered out in the handler's own predicate rather than left to the `ORG_UNIT` overlay, which is inert for a caller who did not ask for org-unit confinement. Requesting `scope=DIVISION` here is a `422`, not an empty page: those rows belong to `admin.division_summary.read` and `admin.division_roster.list`, behind their own tokens and closure.\n",
        "tags": [
          "xc",
          "dashboard"
        ],
        "x-token": "xc.dashboard_projection.list",
        "x-realizes-features": [
          "XC-F09"
        ],
        "x-screens": [
          "XC-S07",
          "XC-S08"
        ],
        "x-touches-entities": [
          "xc.dashboard_projections"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "scope",
            "in": "query",
            "required": false,
            "description": "Defaults to the caller's own persona scope; a manager may additionally request `MANAGER`.",
            "schema": {
              "$ref": "#/components/schemas/DashboardScope"
            }
          },
          {
            "name": "projection_key",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          }
        ],
        "responses": {
          "200": {
            "description": "Page of the caller's dashboard tiles.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DashboardProjectionPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/finance-kpis/headcount-cost": {
      "get": {
        "operationId": "xc.finance_kpi.headcount_cost.read",
        "summary": "Read the tenant-wide headcount-cost KPI projection (projected from executed/published payroll runs)",
        "tags": [
          "xc",
          "dashboard"
        ],
        "x-token": "xc.finance_kpi.headcount_cost.read",
        "x-realizes-features": [
          "PAY-F08"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.dashboard_projections",
          "pay.payroll_runs"
        ],
        "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": [
          {
            "in": "query",
            "name": "period",
            "required": false,
            "description": "KPI month to read (YYYY-MM). The queues remain live and ignore this parameter.",
            "schema": {
              "type": "string",
              "pattern": "^\\\\d{4}-(0[1-9]|1[0-2])$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Latest `finance.headcount_cost` projection, or `data: null` when no source projection exists.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FinanceKpiRead"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/finance-kpis/utilization": {
      "get": {
        "operationId": "xc.finance_kpi.utilization.read",
        "summary": "Read the tenant-wide utilization KPI projection",
        "tags": [
          "xc",
          "dashboard"
        ],
        "x-token": "xc.finance_kpi.utilization.read",
        "x-realizes-features": [
          "PAY-F08"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.dashboard_projections"
        ],
        "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": [
          {
            "in": "query",
            "name": "period",
            "required": false,
            "description": "KPI month to read (YYYY-MM). The queues remain live and ignore this parameter.",
            "schema": {
              "type": "string",
              "pattern": "^\\\\d{4}-(0[1-9]|1[0-2])$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Latest `finance.utilization` projection, or `data: null` when no source projection exists.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FinanceKpiRead"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/finance-kpis/invoiced-vs-cost": {
      "get": {
        "operationId": "xc.finance_kpi.invoiced_vs_cost.read",
        "summary": "Read the tenant-wide invoiced-versus-cost KPI projection",
        "tags": [
          "xc",
          "dashboard"
        ],
        "x-token": "xc.finance_kpi.invoiced_vs_cost.read",
        "x-realizes-features": [
          "PAY-F08"
        ],
        "x-screens": [],
        "x-touches-entities": [
          "xc.dashboard_projections"
        ],
        "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": [
          {
            "in": "query",
            "name": "period",
            "required": false,
            "description": "KPI month to read (YYYY-MM). The queues remain live and ignore this parameter.",
            "schema": {
              "type": "string",
              "pattern": "^\\\\d{4}-(0[1-9]|1[0-2])$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Latest `finance.invoiced_vs_cost` projection, or `data: null` when no source projection exists.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FinanceKpiRead"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/home-widgets": {
      "get": {
        "operationId": "xc.home_widget.list",
        "summary": "Read the role-aware home layout",
        "description": "The configurable widget set/order/visibility composing the Home/Work/Profile tab surfaces (fsd 02 XC-S07 quick-actions row, XC-S08/XC-S10 tab composition; db 14 §5 `xc.home_widgets`, XC-F09) — pure layout config, tenant-overridable over seeded system defaults; the *content* is `xc.dashboard_projections`. Sort whitelist: `position`.\n",
        "tags": [
          "xc",
          "dashboard"
        ],
        "x-token": "xc.home_widget.list",
        "x-realizes-features": [
          "XC-F09"
        ],
        "x-screens": [
          "XC-S07",
          "XC-S08",
          "XC-S10"
        ],
        "x-touches-entities": [
          "xc.home_widgets"
        ],
        "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": "scope",
            "in": "query",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/DashboardScope"
            }
          },
          {
            "name": "surface",
            "in": "query",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/WidgetSurface"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `position`. Default `position`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of the requested role/surface's home layout.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HomeWidgetPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/home/me": {
      "get": {
        "operationId": "xc.employee_home.get",
        "summary": "Read the caller's mobile Home screen in one call",
        "description": "The single aggregate the Flutter Home tab (fsd 02 `XC-S07`) opens with: greeting name, today's check-in state and shift window, the leave-balance cards, and the announcements carousel — one round trip instead of four on a cold, often-mobile connection.\n\n**Deliberate deviation from `XC-S07`'s \"reads only `xc` read-models, no cross-schema joins\".** The event-built `xc.dashboard_projections` pipeline that binding assumes is not built yet, so this operation composes the owning modules' **live** reads server-side. It is not a paraphrase of them: the leave aggregation and the announcement feed predicate are the *same* SQL `leave.leave_balance.summary` and `engage.announcement.list` run, so the numbers here and the numbers on those endpoints cannot disagree. When the projections land, this operation keeps its contract and changes its source — carried openly here rather than silently (00 §6).\n\n`today` is null only when the caller has neither an attendance record nor a shift assignment for today; `today.shift` is null when there is a record but no shift behind it. `checked_in_now` is the server-resolved current punch-session state shared by mobile and ESS; do not infer it from the daily first-IN/last-OUT columns, because a day may contain several sessions. `announcements` is the first 5 rows `engage.announcement.list` would return (PUBLISHED, unexpired, audience-targeted, pinned first then newest); `leave_balances` is every row `leave.leave_balance.summary` would return for the current caller, ordered by display name.\n\n**`GAP-ESS-01` (design-ess/02 §12) — the web Home `XC-S16` blocks land on this SAME operation.** Gate G2 binds that route to exactly one `/api/v1/*` read, so `next_pay_day`, `last_net_pay`, `my_pending_requests` and the approvals counts are additions here rather than four more endpoints: one token, one SELF scope, one transaction. Each is nullable/empty by design — an employee with no published payslip, no pay calendar and no request in flight is a *designed first-run state*, never a zero.\n\n`next_pay_day` is the caller's legal entity's earliest `pay.payroll_runs.pay_date` on or after today (non-cancelled). **`pay.pay_periods` does not exist yet** — it arrives with the pay-attend wave, and when it does this field changes SOURCE and keeps its contract, exactly as the paragraph above describes for the projections. Null when no such run exists; never derived or projected. `last_net_pay` is the caller's own latest PUBLISHED payslip and carries the driver's decimal string verbatim (money doctrine — no float on the way out). `my_pending_requests` is the REQUESTER side of `xc.approval_inbox` (the union already modelled across leave, overtime, regularization, timesheets, expense and offers), newest first with `Returned` rows sorted to the top, its `status` mapped at this edge into the ESS request-lifecycle vocabulary. `approvals_pending_count` / `approvals_overdue_count` are the APPROVER side of the same projection and are non-null **only** when the caller holds `xc.approval_inbox.list`; both are null together for a caller who is not an approver, which is a different fact from `0`.\n\n**`GAP-ESS-02` (design-ess/02 §12) — `who_is_out_today` closes flagship 2 on this same operation**, for the same G2 reason. Reach is the owner's decision of 2026-08-13 and is the caller's immediate org neighbourhood only: their peers (everyone reporting to the caller's manager), their manager, and their own direct reports — **not** the department and **not** the tenant. The block is `null` exactly when that set is empty, and the card then renders NOT AT ALL; a block with two empty arrays is the different, stateable fact \"everyone's in today\". `out_today[]` discloses only that a named teammate is on approved leave today and the leave type's name — never the reason, the dates or the application. `moments[]` discloses a name, a moment kind and (for an anniversary) a year count; the birthday match is made server-side on day-and-month and **no date of birth is ever returned** — a `BIRTHDAY`/`WORK_ANNIVERSARY` row's `date` is TODAY, and only a `NEW_JOINER`'s `date` is that employee's own joining date.\n\n**`moments[]` is gated on each subject's own `contact_visibility`** (`GAP-XRS-2`), read from `people.org_directory.visibility` — the projection `PATCH /app-settings/me` keeps in step with `people.app_settings.contact_visibility`. `PUBLIC`/`COLLEAGUES` publish the moment to the team; `MANAGER_HR` publishes it to that subject's own manager only; `PRIVATE` publishes it to nobody, their manager included. Unrecognised values fail closed. **`out_today[]` is deliberately NOT gated**: `contact_visibility` governs how much of yourself the org publishes — the directory masks `work_email`/`phone` on it, never employment facts — and \"on approved leave today\" is the operational fact the owner's 2026-08-13 reach decision sanctioned, not a fact about a private life. A `PRIVATE` teammate appears in `out_today[]` and never in `moments[]`.\n\n**`GAP-35` (api-docs 05, closed by #963) — `assigned_geofences[]` embeds the caller's OWN `org.geofences` row(s), never the tenant's whole list.** `GET /geofences` is org-admin-scoped (`org.geofence.list`); the PWA needs to read its OWN assignment to evaluate punch containment ON-DEVICE (ADR 0004), so this rides the aggregate's existing `xc.employee_home.get` authority rather than adding a second token or a second round trip — the same G2/one-token reasoning as the `GAP-ESS-01`/`GAP-ESS-02` blocks above. Scoping is structural, not a permission check: the set is bounded to today's shift assignment's own fence, or every active fence at that assignment's (or, absent one, the caller's own) work location — ids the caller cannot widen because this operation takes none as input. Always `[]`, never `null`, when neither signal resolves.\n\n**Mobile attendance policy.** `allow_mobile_attendance_anywhere` is the explicit HR/Admin toggle. When it is `true`, mobile clients must not request location permission, read GPS, or run a geofence check for IN or OUT; face verification remains required. `mobile_location_attestation_required` is the effective result after placement and allowed-location policy resolution. `attendance_location_unrestricted` is true only for an explicit unrestricted Remote assignment. A failed policy read must fail closed.\n\n**A CLAIM, never a verdict (ADR 0004/0025).** `assigned_geofences[]` is cached geometry for the handset's own ON-DEVICE pre-check — it is not, and never becomes, an attendance verdict. The server-authoritative containment test lives entirely on the punch write path (`POST /attendance-punches/sync`, `xc.employee_home.get` this operation plays no part in it), which re-derives containment itself from the punch's own coordinates and OVERRIDES whatever the client claims (ADR 0025). A client that reported `MATCH` off this cached fence and a server that disagrees is exactly the scenario ADR 0025 exists to catch.\n",
        "tags": [
          "xc",
          "dashboard"
        ],
        "x-token": "xc.employee_home.get",
        "x-realizes-features": [
          "XC-F09",
          "XC-F12",
          "ATT-F01",
          "LVE-F01",
          "ENG-F04",
          "PAY-F03"
        ],
        "x-screens": [
          "XC-S07",
          "XC-S16"
        ],
        "x-touches-entities": [
          "people.employees",
          "people.employee_profiles",
          "people.org_directory",
          "attend.attendance_records",
          "attend.shift_assignments",
          "org.shifts",
          "org.geofences",
          "leave.leave_balances",
          "leave.leave_applications",
          "org.leave_types",
          "engage.announcements",
          "engage.announcement_reads",
          "pay.payroll_runs",
          "pay.payslips",
          "xc.approval_inbox"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "responses": {
          "200": {
            "description": "The caller's home composition.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmployeeHome"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/approval-inbox": {
      "get": {
        "operationId": "xc.approval_inbox.list",
        "summary": "List the caller's pending (and past) approvals",
        "description": "The single cross-module approvals queue (fsd 02 XC-S15; db 14 §6 `xc.approval_inbox`, XC-F12) — leave, expense, timesheet, OT, offers, regularization, F&F, promotions, loan/advance, routed by `current_approver_id` **or** `effective_approver_id` (an active `xc.delegations` rule, XC-F14). An **event-built projection**: it owns the queue, not the truth — the source record lives in its owning module. Sort whitelist: `sla_due_at`, `created_at`, `priority`. Default `-priority,sla_due_at`.\n\nTwo orthogonal axes select the window (design-ess/03 §11, #830). `as` chooses which side of the row caller stands on: `approver` (default — routed to me), `requester` (raised BY me), or `both` (the deduplicated union for All pending). `view` is the Pending|Decided segmented control: `pending` (default) or `decided` (`status IN (APPROVED, REJECTED)`, ordered by `decided_at` descending). The two compose — under `view=decided` an explicit `status` narrows WITHIN the decided set (the outcome filter) and must be `APPROVED` or `REJECTED`; any non-terminal `status` there is a 422 rather than a silently empty page.\n\n`facet=type` (GAP-37, issue #965) swaps the paginated `data`/`page` response for `ApprovalInboxFacetCounts` — per-type counts across the WHOLE `as`/`view`/`status`/`priority`- filtered set, for the PWA approvals inbox's filter chips. It is grouped by `source_type`, not `request_type` (see that schema's description for why `request_type` alone cannot back this), and is not combinable with `request_type`. List responses return a stable `page.next_cursor` when `page.has_more` is true; send it as `page[after]` to continue the same filter window without repeating rows.\n",
        "tags": [
          "xc",
          "approval-inbox"
        ],
        "x-token": "xc.approval_inbox.list",
        "x-realizes-features": [
          "XC-F12",
          "XC-F14"
        ],
        "x-screens": [
          "XC-S07",
          "XC-S15"
        ],
        "x-touches-entities": [
          "xc.approval_inbox"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "as",
            "in": "query",
            "required": false,
            "description": "Which side of the row the caller stands on. `approver` (default) matches `current_approver_id` or `effective_approver_id`; `requester` matches `requested_by` and is what backs the Inbox 'My requests' section (legal under the migration-0094 RLS arm). `both` matches either side once, for the All pending opening view.\n",
            "schema": {
              "type": "string",
              "enum": [
                "approver",
                "requester",
                "both"
              ],
              "default": "approver"
            }
          },
          {
            "name": "view",
            "in": "query",
            "required": false,
            "description": "The Pending|Decided segmented control. `decided` selects `status IN (APPROVED, REJECTED)` ordered newest-decided-first; combining it with `status=APPROVED` or `status=REJECTED` narrows within that set, while any non-terminal `status` is refused. `pending` is the DEFAULT and imposes no window of its own — it is the pre-#830 behaviour, so with no `status` it still returns every status, ordered newest-raised-first. The ESS Inbox's Pending segment always sends an explicit `status` alongside it.\n",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "decided"
              ],
              "default": "pending"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/ApprovalInboxStatus"
            }
          },
          {
            "name": "request_type",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/ApprovalRequestType"
            }
          },
          {
            "name": "source_type",
            "in": "query",
            "required": false,
            "description": "Narrow to one OWNING SOURCE — the list-side twin of `facet=type`'s grouping key (issue #1325). This is what a type chip sends: `request_type` cannot back one, because `attend.out_of_zone_events` and `recruit.requisitions` both ride `request_type = 'OTHER'`, and client-side filtering cannot either, because the page caps at 200 rows (GAP-37 measured a 500-item queue undercounting by ~60%).\n\nIt composes with — never replaces — `as`, `view`, `status`, `request_type` and `priority`: the predicate is appended to the very same `WHERE` the facet aggregate runs, so filtering to a chip returns exactly the rows that chip counted, and no `source_type` can surface a row the caller could not already list. Ignored under `facet=type` (like `request_type`), since a facet request asks for the breakdown ACROSS types. An unknown value is a `422`, not an empty page.\n",
            "schema": {
              "$ref": "#/components/schemas/ApprovalSourceType"
            }
          },
          {
            "name": "priority",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/ApprovalPriority"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `sla_due_at`, `created_at`, `priority`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "facet",
            "in": "query",
            "required": false,
            "description": "`type` swaps the paginated response for `ApprovalInboxFacetCounts` — per-type counts over the whole filtered set (GAP-37, issue #965). Not combinable with `request_type` or `source_type`; use `source_type` on a second, non-facet call to fetch the rows behind one of the counts it returns.\n",
            "schema": {
              "type": "string",
              "enum": [
                "type"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of approvals routed to the caller (direct or delegated), or raised by them — or, with `facet=type`, the per-type counts over that same filtered set.\n",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ApprovalInboxPage"
                    },
                    {
                      "$ref": "#/components/schemas/ApprovalInboxFacetCounts"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/approval-inbox/admin": {
      "get": {
        "operationId": "xc.approval_inbox.list_admin",
        "summary": "List tenant-wide approvals (Admin)",
        "description": "The single cross-module approvals queue across the tenant for HR / Tenant Admins (XC-F12). Requires both `xc.approval_inbox.list_admin` and `xc.approval_inbox.decide_override` with a linked employee identity. List responses return `page.next_cursor` when more rows exist; pass it as `page[after]`.",
        "tags": [
          "xc",
          "approval-inbox"
        ],
        "x-token": "xc.approval_inbox.list_admin",
        "x-realizes-features": [
          "XC-F12",
          "XC-F14"
        ],
        "x-screens": [
          "XC-S15",
          "ATT-S13"
        ],
        "x-touches-entities": [
          "xc.approval_inbox"
        ],
        "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": "status",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/ApprovalInboxStatus"
            }
          },
          {
            "name": "request_type",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/ApprovalRequestType"
            }
          },
          {
            "name": "source_type",
            "in": "query",
            "required": false,
            "description": "Narrow to one owning source, tenant-wide (issue #1325) — identical semantics to the same parameter on `GET /approval-inbox`: composed onto the existing `status` / `unrouted` / `request_type` / `priority` / `employee_id` predicates, validated against the same closed set that backs `facet=type`'s categories, ignored under `facet=type`, `422` on an unknown value.\n",
            "schema": {
              "$ref": "#/components/schemas/ApprovalSourceType"
            }
          },
          {
            "name": "priority",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/ApprovalPriority"
            }
          },
          {
            "name": "unrouted",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "module_fallback",
            "in": "query",
            "required": false,
            "description": "Narrow to rows the OWNING MODULE routed (issue #1498) — `routing_resolver LIKE 'MODULE_FALLBACK%'` AND `unrouted = false`. `true` selects them, `false` excludes them. It NARROWS this operation's existing result set and never widens it, which is what lets the back-office rail read an exact `facet=type` count for the module-fallback lens on this route under `xc.approval_inbox.list_admin` while the LIST read is gated on `xc.approval_inbox.list_module_fallback`. Composed onto the `status` / `unrouted` / `request_type` / `source_type` / `priority` / `employee_id` predicates.\n",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "employee_id",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/UuidRef"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `sla_due_at`, `created_at`, `priority`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "facet",
            "in": "query",
            "required": false,
            "description": "`type` swaps the paginated response for ApprovalInboxFacetCounts.",
            "schema": {
              "type": "string",
              "enum": [
                "type"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of tenant-wide approvals.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ApprovalInboxPage"
                    },
                    {
                      "$ref": "#/components/schemas/ApprovalInboxFacetCounts"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/approval-inbox/admin/unrouted": {
      "get": {
        "operationId": "xc.approval_inbox.list_unrouted",
        "summary": "List unrouted approval requests (Admin)",
        "description": "Discover approval requests the tenant's routing could not place — `unrouted = true`: at projection time, both approver columns null with a `routing_failure` of `UNRESOLVED_APPROVER`, `UNSAFE_HISTORICAL_FALLBACK` or `NO_PUBLISHED_RULE`; afterwards, also a breached row whose escalation target resolved to nobody, which `xc.sla_sweep_breaches` (migration `0160`) flags while LEAVING the approver in place. The flag is therefore \"routing has nowhere left to send this\", not \"this row has no approver\" — and Re-route is the repair in both cases. Requires both `xc.approval_inbox.list_unrouted` and `xc.approval_inbox.decide_override` with a linked employee identity. The list token remains separate from the full admin queue token.\n\n**This lens does NOT contain module-fallback rows (#1492, #1498).** A row the owning module routed — the envelope named an approver the chain could not — is `unrouted = false` and carries its pre-empted reason on `routing_resolver` as `MODULE_FALLBACK:<reason>`. Those rows have an approver and must not be offered a Re-route, which would only re-derive the approver they already have; they inhabit `GET /approval-inbox/admin/module-fallback` (`xc.approval_inbox.list_module_fallback`). This description previously implied otherwise.",
        "tags": [
          "xc",
          "approval-inbox"
        ],
        "x-token": "xc.approval_inbox.list_unrouted",
        "x-realizes-features": [
          "XC-F12"
        ],
        "x-screens": [
          "XC-S15",
          "ATT-S13"
        ],
        "x-touches-entities": [
          "xc.approval_inbox"
        ],
        "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": "request_type",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/ApprovalRequestType"
            }
          },
          {
            "name": "source_type",
            "in": "query",
            "required": false,
            "description": "Narrow the unrouted lens to one owning source (issue #1325). This operation is served by the same handler as `GET /approval-inbox/admin` with `unrouted=true` pinned, so the parameter has identical semantics here — it NARROWS the unrouted predicate rather than escaping it, and a `422` refuses an unknown value.\n",
            "schema": {
              "$ref": "#/components/schemas/ApprovalSourceType"
            }
          },
          {
            "name": "priority",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/ApprovalPriority"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `sla_due_at`, `created_at`, `priority`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of unrouted approval requests.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApprovalInboxPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/approval-inbox/admin/module-fallback": {
      "get": {
        "operationId": "xc.approval_inbox.list_module_fallback",
        "summary": "List approvals routed by the owning module (Admin)",
        "description": "The operator lens for rows the OWNING MODULE routed because the tenant's routing configuration could not (#1498, follow-up to #1492): `status = 'PENDING'`, `unrouted = false`, `routing_resolver LIKE 'MODULE_FALLBACK%'`. Requires both `xc.approval_inbox.list_module_fallback` and `xc.approval_inbox.decide_override` with a linked employee identity.\n\n**These rows are not stuck — they are already with an approver.** What is broken is the configuration behind them, and the repair differs per row, which is why the response projects a derived `module_fallback_reason`: `NO_PUBLISHED_RULE` (charter a rule), `UNRESOLVED_APPROVER` (staff the role the chain resolves to), `INVALID_RULE_CHAIN` (fix the rule's chain). `null` when the writer recorded no reason — the bare `MODULE_FALLBACK` — or when the suffix is one this build does not name.\n\n**No Re-route affordance belongs on this lens.** `POST /approval-inbox/{id}/reroute` re-derives an approver these rows already have, so it would repair nothing and would obscure the configuration gap the row exists to report.\n\nIts own list token is separate from the full admin queue token, while tenant override authority is required for any tenant-wide read. Served by the `GET /approval-inbox/admin` handler with `module_fallback=true` pinned, so the whole admin filter set applies and narrows within the lens rather than escaping it.",
        "tags": [
          "xc",
          "approval-inbox"
        ],
        "x-token": "xc.approval_inbox.list_module_fallback",
        "x-realizes-features": [
          "XC-F12"
        ],
        "x-screens": [
          "XC-S15"
        ],
        "x-touches-entities": [
          "xc.approval_inbox"
        ],
        "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": "status",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/ApprovalInboxStatus"
            }
          },
          {
            "name": "request_type",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/ApprovalRequestType"
            }
          },
          {
            "name": "source_type",
            "in": "query",
            "required": false,
            "description": "Narrow the module-fallback lens to one owning source — identical semantics to the same parameter on `GET /approval-inbox/admin`, validated against the same closed set, `422` on an unknown value.\n",
            "schema": {
              "$ref": "#/components/schemas/ApprovalSourceType"
            }
          },
          {
            "name": "priority",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/ApprovalPriority"
            }
          },
          {
            "name": "employee_id",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/UuidRef"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `sla_due_at`, `created_at`, `priority`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of module-fallback approval requests.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApprovalInboxPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/approval-inbox/{id}": {
      "get": {
        "operationId": "xc.approval_inbox.get",
        "summary": "Get one approval-inbox item",
        "description": "The XC-S15 detail card/drawer for a single pending (or decided) approval (db 14 §6). Readable by the routed approver (`current_approver_id` / `effective_approver_id`) and, since #830, by the employee who RAISED it (`requested_by`) so the 'My requests' decision-record drawer can open. Read-only widening: deciding still refuses a self-raised row (four-eyes).\n",
        "tags": [
          "xc",
          "approval-inbox"
        ],
        "x-token": "xc.approval_inbox.get",
        "x-realizes-features": [
          "XC-F12",
          "XC-F14"
        ],
        "x-screens": [
          "XC-S15"
        ],
        "x-touches-entities": [
          "xc.approval_inbox"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "The approval-inbox item.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApprovalInboxItem"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/approval-inbox/{id}/decide": {
      "post": {
        "operationId": "xc.approval_inbox.decide",
        "summary": "Approve or reject an inbox item (routes to the owning module)",
        "description": "The XC-S15 inline **Approve**/**Reject** action validates the local envelope then calls the owning module's in-process service seam with the original authenticated request. Request-bound source operations re-run their own token, scope, maker-checker, step-up and evidence checks; Recruit's inbox-only handlers re-run the XC decision token before their own routing/maker-checker checks. XC never writes the source schema.\n\n**Who may decide (`#1489`).** For every request type but two, the caller must be the row's `current_approver_id` or `effective_approver_id`; anyone else gets `404`. On `REQUISITION` and `OFFER` the caller may ALSO be role-eligible — a holder of `hr_admin`, `recruiter`, `tenant_admin` or `owner`, or of any `ROLE:` key the tenant's published rule for that type names — and not the maker. \"Holder\" counts a grant on EITHER `admin.member_grants` subject partition: an `EMPLOYEE`-keyed grant, or the `WORKSPACE_MEMBER`-keyed grant a back-office seat carries, reached through its ACTIVE `xc.identities` row. ADR 0024 keeps a grant inside its `subject_kind`, so a portal account holding `hr_admin` holds it on the second partition and nowhere else. This is not an override and is not recorded as one: under the owner decision of 2026-08-30 a peer of the routed approver taking the decision is the ORDINARY case. Four-eyes is unaffected and unconditional. Note the personal inbox lens (`GET /approval-inbox`) still lists only rows routed TO the caller, so an eligible peer reaches the decision from the source record's own `approval` block (`GET /requisitions/{id}`, `GET /offers/{id}`), not from their inbox. Only after that succeeds does XC stamp the inbox row and emit `xc.approval_inbox.decided`. A source refusal leaves the row pending and records its last refusal code/time. Concurrent decision races are real, so `If-Match` is required against the inbox row. The **decide-race** — a second approver (co-approver or delegate) losing to the first — surfaces as `409` with the enriched `ApprovalDecisionConflictProblem` body (`decided_by`/`decided_by_id`), not the plain shared `Conflict`, so the PWA's \"Already decided by <name>\" toast (design-pwa/05 §4) needs no follow-up GET.\n",
        "tags": [
          "xc",
          "approval-inbox"
        ],
        "x-token": "xc.approval_inbox.decide",
        "x-realizes-features": [
          "XC-F12",
          "XC-F14"
        ],
        "x-screens": [
          "XC-S15"
        ],
        "x-touches-entities": [
          "xc.approval_inbox"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": "xc.approval_inbox.decided",
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "$ref": "#/components/parameters/IfMatch"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ApprovalDecisionInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The owning module accepted the transition and the inbox row was stamped decided.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApprovalInboxItem"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "State-transition conflict. On the decide-race specifically (the row is no longer `PENDING` because another approver already decided it), the body is an `ApprovalDecisionConflictProblem` carrying `decided_by`/`decided_by_id` — see the operation description. Other `409` causes on this route keep the plain `Problem` shape.\n",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ApprovalDecisionConflictProblem"
                }
              }
            }
          },
          "412": {
            "$ref": "#/components/responses/PreconditionFailed"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "423": {
            "$ref": "#/components/responses/Locked"
          },
          "428": {
            "$ref": "#/components/responses/PreconditionRequired"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/approval-inbox/{id}/override": {
      "post": {
        "operationId": "xc.approval_inbox.decide_override",
        "summary": "Approve or reject an inbox item routed to somebody else (audited override)",
        "description": "The tenant-ceiling escape hatch for a stalled queue (#1415). `POST /approval-inbox/{id}/decide` admits exactly the routed approver (`current_approver_id` / `effective_approver_id`) and answers everybody else with `404`, so an `owner` or `tenant_admin` who can SEE the whole tenant queue via `GET /approval-inbox/admin` can act on none of it. This operation is the same decision, taken by a holder of `xc.approval_inbox.decide_override` who is NOT the routed approver. It is a separate operation rather than a branch of `decide` because it must run under `tenant` RLS — under `decide`'s `self` scope a non-routed principal cannot even read the row. Two rules are unchanged: the four-eyes bar (a caller may never decide a request they raised themselves — `requested_by = caller` is refused `403`, not `404`) and the maker-checker kernel, which compares actor identities per item and takes no token input. The override is recorded on the row (`override_by` / `override_of`), carried on `xc.approval_inbox.decided` as `override: true`, and the routed approver is notified that their item was decided over them. A holder who IS the routed approver may use this route; the decision is then recorded as an ordinary one (no `override_by`), so the audit trail never claims an override that did not happen. The caller must have an employee identity — a back-office-only principal is refused `403`, since the decision needs a person to attribute.\n",
        "tags": [
          "xc",
          "approval-inbox"
        ],
        "x-token": "xc.approval_inbox.decide_override",
        "x-realizes-features": [
          "XC-F12",
          "XC-F14"
        ],
        "x-screens": [
          "XC-S15"
        ],
        "x-touches-entities": [
          "xc.approval_inbox"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": "xc.approval_inbox.decided",
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "$ref": "#/components/parameters/IfMatch"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ApprovalDecisionInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The owning module accepted the transition and the inbox row was stamped decided.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApprovalInboxItem"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "State-transition conflict. On the decide-race (the row is no longer `PENDING` because the routed approver, or another override holder, already decided it) the body is an `ApprovalDecisionConflictProblem` carrying `decided_by`/`decided_by_id`, exactly as on `decide`.\n",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ApprovalDecisionConflictProblem"
                }
              }
            }
          },
          "412": {
            "$ref": "#/components/responses/PreconditionFailed"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "423": {
            "$ref": "#/components/responses/Locked"
          },
          "428": {
            "$ref": "#/components/responses/PreconditionRequired"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/approval-inbox/{id}/reroute": {
      "post": {
        "operationId": "xc.approval_inbox.reroute",
        "summary": "Re-route an unrouted approval request",
        "description": "Re-evaluates canonical routing rules for an unrouted or pending approval request across the tenant.",
        "tags": [
          "xc",
          "approval-inbox"
        ],
        "x-token": "xc.approval_inbox.reroute",
        "x-realizes-features": [
          "XC-F12"
        ],
        "x-screens": [
          "XC-S15",
          "ATT-S13"
        ],
        "x-touches-entities": [
          "xc.approval_inbox",
          "leave.leave_approvals"
        ],
        "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": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "$ref": "#/components/parameters/IfMatch"
          }
        ],
        "responses": {
          "200": {
            "description": "The approval request was re-routed through canonical routing logic.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApprovalInboxItem"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "412": {
            "$ref": "#/components/responses/PreconditionFailed"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/approval-inbox/bulk-decide": {
      "post": {
        "operationId": "xc.approval_inbox.bulk_decide",
        "summary": "Approve or reject multiple inbox items in one call",
        "description": "§W grid multi-select bulk action (fsd 02 XC-S15, db 14 §6: *\"Bulk actions and SLAs are XC-F12\"*). Applies each item through the same in-process owning-service seam as the single action. The stable outer idempotency key is derived per item, so a retry after a later item fails replays earlier successes rather than applying them twice. Each row's optimistic-concurrency check travels in the request body (`version`) rather than one header because the batch spans multiple envelopes.\n",
        "tags": [
          "xc",
          "approval-inbox"
        ],
        "x-token": "xc.approval_inbox.bulk_decide",
        "x-realizes-features": [
          "XC-F12",
          "XC-F14"
        ],
        "x-screens": [
          "XC-S15"
        ],
        "x-touches-entities": [
          "xc.approval_inbox"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": "xc.approval_inbox.decided",
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BulkApprovalDecisionInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Every owning module accepted its transition; returns the decided inbox rows.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BulkApprovalDecisionResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "412": {
            "$ref": "#/components/responses/PreconditionFailed"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "423": {
            "$ref": "#/components/responses/Locked"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/delegations": {
      "get": {
        "operationId": "xc.delegation.list",
        "summary": "List delegations (as delegator or delegatee)",
        "description": "Date-bounded approval/act-on-behalf delegations (fsd 02 XC-S15 delegation banner; db 14 §6 `xc.delegations`, XC-F14). Sort whitelist: `start_date`, `-start_date`.\n",
        "tags": [
          "xc",
          "delegation"
        ],
        "x-token": "xc.delegation.list",
        "x-realizes-features": [
          "XC-F14"
        ],
        "x-screens": [
          "XC-S15"
        ],
        "x-touches-entities": [
          "xc.delegations"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": "xc.delegation",
        "x-provisional": null,
        "parameters": [
          {
            "name": "as",
            "in": "query",
            "required": false,
            "description": "Perspective filter — defaults to both.",
            "schema": {
              "type": "string",
              "enum": [
                "DELEGATOR",
                "DELEGATEE"
              ]
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/DelegationStatus"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `start_date`, `-start_date`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of delegations.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DelegationPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "post": {
        "operationId": "xc.delegation.create",
        "summary": "Create a delegation",
        "description": "The caller hands their delegable approvals/actions to a delegatee for a date-bounded window (fsd 02 XC-S15; db 14 §6). `scope_tokens` must be a subset of the delegator's delegable tokens (service-validated against the Security/RBAC catalogue); `delegator_id` defaults to the caller.\n",
        "tags": [
          "xc",
          "delegation"
        ],
        "x-token": "xc.delegation.create",
        "x-realizes-features": [
          "XC-F14"
        ],
        "x-screens": [
          "XC-S15"
        ],
        "x-touches-entities": [
          "xc.delegations"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": "xc.delegation.created",
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": "xc.delegation",
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DelegationCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Delegation created.",
            "headers": {
              "Location": {
                "$ref": "#/components/headers/Location"
              },
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Delegation"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "423": {
            "$ref": "#/components/responses/Locked"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/delegations/{id}": {
      "get": {
        "operationId": "xc.delegation.get",
        "summary": "Get one delegation",
        "tags": [
          "xc",
          "delegation"
        ],
        "x-token": "xc.delegation.get",
        "x-realizes-features": [
          "XC-F14"
        ],
        "x-screens": [
          "XC-S15"
        ],
        "x-touches-entities": [
          "xc.delegations"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": "xc.delegation",
        "x-provisional": null,
        "description": "db 14 §6 `xc.delegations` detail.",
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "The delegation.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Delegation"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "patch": {
        "operationId": "xc.delegation.update",
        "summary": "Update a delegation's window/scope",
        "description": "Adjusts `start_date`/`end_date`/`scope_tokens`/`request_types`/`reason` ahead of activation (db 14 §6).",
        "tags": [
          "xc",
          "delegation"
        ],
        "x-token": "xc.delegation.update",
        "x-realizes-features": [
          "XC-F14"
        ],
        "x-screens": [
          "XC-S15"
        ],
        "x-touches-entities": [
          "xc.delegations"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": "xc.delegation.updated",
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": "xc.delegation",
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "$ref": "#/components/parameters/IfMatch"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DelegationUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Delegation updated.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Delegation"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "412": {
            "$ref": "#/components/responses/PreconditionFailed"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "423": {
            "$ref": "#/components/responses/Locked"
          },
          "428": {
            "$ref": "#/components/responses/PreconditionRequired"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/delegations/{id}/revoke": {
      "post": {
        "operationId": "xc.delegation.revoke",
        "summary": "Revoke a delegation early",
        "description": "`status → REVOKED` ahead of `end_date` (db 14 §6 lifecycle); approvals immediately stop routing to the delegatee.",
        "tags": [
          "xc",
          "delegation"
        ],
        "x-token": "xc.delegation.revoke",
        "x-realizes-features": [
          "XC-F14"
        ],
        "x-screens": [
          "XC-S15"
        ],
        "x-touches-entities": [
          "xc.delegations"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": "xc.delegation.revoked",
        "x-rls-scope": "self",
        "x-append-only": false,
        "x-entitlement": "xc.delegation",
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "$ref": "#/components/parameters/IfMatch"
          }
        ],
        "responses": {
          "200": {
            "description": "Delegation revoked.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Delegation"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "412": {
            "$ref": "#/components/responses/PreconditionFailed"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "428": {
            "$ref": "#/components/responses/PreconditionRequired"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/approval-rules": {
      "get": {
        "operationId": "xc.approval_rule.list",
        "summary": "List approval routing rules",
        "description": "The `ADM-S09` grid — rules grouped by `request_type` and ordered by band floor, each showing its chain summary (`DIRECT_MANAGER → ROLE:finance`), step timeout, escalation target, version and status chip. Also carries the **unrouted banner's** input: request types with **no published rule**, whose requests fall back to the owning module's historical default and land on `xc.approval_inbox` flagged `unrouted` — surfaced here rather than swallowed (ADR 0027 §(c)). Seeded defaults reproduce today's implicit routing exactly (leave → `DIRECT_MANAGER`; timesheet → the PM review chain; expense → manager then `ROLE:finance`), so the grid is never blank and adopting the engine changes *where* routing is defined, not what it does on day one. `INVOICE` is NOT among them since #1333: it was seeded `ROLE:finance` from migration `0089` and is the one `xc.approval_request_type` member with no backing table in any schema, so the chain was a configurable affordance for a queue that can only be a zero. It now appears in this response's `unrouted` block with its fallback sentence instead, and the chain returns in the change that ships `billing`. Sort whitelist: `request_type`, `band_min_amount`, `-version`.\n",
        "tags": [
          "xc",
          "approval-rule"
        ],
        "x-token": "xc.approval_rule.list",
        "x-realizes-features": [
          "XC-F16"
        ],
        "x-screens": [
          "ADM-S09"
        ],
        "x-touches-entities": [
          "xc.approval_rules"
        ],
        "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": "request_type",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/ApprovalRequestType"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/ApprovalRuleStatus"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `request_type`, `band_min_amount`, `-version`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of routing rules, plus the unrouted request types.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApprovalRulePage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "post": {
        "operationId": "xc.approval_rule.create",
        "summary": "Create an approval routing rule (draft)",
        "description": "Creates a `DRAFT` rule (`ADM-S09` **New rule**). Every chain step names a **relationship, never a named person** — `DIRECT_MANAGER`, `ORG_UNIT_HEAD`, or `ROLE:<catalog_key>` resolving against `admin.role_catalog` — because requester-picked approvers are an integrity hole (ADR 0027, *Alternatives considered*). `step_no` is contiguous from 1. **Bands compare only within the request's own currency**: there is no cross-currency normalization, so `currency_code` is required whenever either band bound is set (ADR 0027, *Consequences*). `step_timeout_hours` defaults to **48**. A `DRAFT` rule routes nothing until published. Editing a **published** rule is not an in-place edit — it mints a new draft version beside it, and seeded defaults are read-only until edited the same way.\n",
        "tags": [
          "xc",
          "approval-rule"
        ],
        "x-token": "xc.approval_rule.create",
        "x-realizes-features": [
          "XC-F16"
        ],
        "x-screens": [
          "ADM-S09"
        ],
        "x-touches-entities": [
          "xc.approval_rules"
        ],
        "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": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ApprovalRuleCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Rule created as `DRAFT`; it routes nothing until published.",
            "headers": {
              "Location": {
                "$ref": "#/components/headers/Location"
              },
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApprovalRule"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "423": {
            "$ref": "#/components/responses/Locked"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/approval-rules/preview": {
      "post": {
        "operationId": "xc.approval_rule.preview",
        "summary": "Resolve a hypothetical request through published and draft routing",
        "description": "ADM-S09's **Test routing** resolver. Applies the router's real order — matching request type and currency band, relationship/role resolution against the live org graph, then today's delegation overlay — to a sample employee without creating an inbox row, notification, audit mutation, or SLA clock. When a draft is supplied, the matching published rule and the draft resolve side by side. Vacant relationships and roles with no active holder are explicit danger findings. This is a read performed through POST only because the hypothetical draft is structured input; it is side-effect-free and safe to retry. The operation requires its own read-only token **and** `xc.approval_rule.list/get`, `people.org_directory.list`, and `org.org_unit.list`; the preview token never bypasses those source-read boundaries.\n",
        "tags": [
          "xc",
          "approval-rule"
        ],
        "x-token": "xc.approval_rule.preview",
        "x-realizes-features": [
          "XC-F16"
        ],
        "x-screens": [
          "ADM-S09"
        ],
        "x-touches-entities": [
          "xc.approval_rules",
          "xc.delegations",
          "people.employees",
          "admin.member_grants"
        ],
        "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,
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ApprovalRulePreviewInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Side-effect-free published-versus-draft routing resolution.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApprovalRulePreview"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/approval-rules/{id}": {
      "get": {
        "operationId": "xc.approval_rule.get",
        "summary": "Get one approval routing rule",
        "description": "The `ADM-S09` rule editor's read — request type, band window and currency, the ordered chain, the step timeout, the escalation target, and the version/status pair. A `PUBLISHED` row is returned read-only: corrections are a **new version**, never an in-place edit (db 14 §6).\n",
        "tags": [
          "xc",
          "approval-rule"
        ],
        "x-token": "xc.approval_rule.get",
        "x-realizes-features": [
          "XC-F16"
        ],
        "x-screens": [
          "ADM-S09"
        ],
        "x-touches-entities": [
          "xc.approval_rules"
        ],
        "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"
          }
        ],
        "responses": {
          "200": {
            "description": "The routing rule.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApprovalRule"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "patch": {
        "operationId": "xc.approval_rule.update",
        "summary": "Edit a draft approval routing rule",
        "description": "**`DRAFT` only.** A `PUBLISHED` row is immutable (`409`) — editing one is *\"open a new draft version\"*, which the console does by creating a rule beside it, never by mutating the live row (`ADM-S09`, db 14 §6). Chain, band, currency, timeout and escalation target are all editable while the rule is a draft; the escalation target's *shape* (exactly one of `NEXT_STEP`, a named relationship, or a role) is fully validated **at publish**, so a half-built draft can still be saved.\n",
        "tags": [
          "xc",
          "approval-rule"
        ],
        "x-token": "xc.approval_rule.update",
        "x-realizes-features": [
          "XC-F16"
        ],
        "x-screens": [
          "ADM-S09"
        ],
        "x-touches-entities": [
          "xc.approval_rules"
        ],
        "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": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "$ref": "#/components/parameters/IfMatch"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ApprovalRuleUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated draft rule.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApprovalRule"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "412": {
            "$ref": "#/components/responses/PreconditionFailed"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "423": {
            "$ref": "#/components/responses/Locked"
          },
          "428": {
            "$ref": "#/components/responses/PreconditionRequired"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/approval-rules/{id}/publish": {
      "post": {
        "operationId": "xc.approval_rule.publish",
        "summary": "Publish a routing rule (make it live)",
        "description": "MC-1 is a two-call lifecycle under this one token: the maker moves `DRAFT → PENDING_PUBLISH`; a **different** granted principal moves `PENDING_PUBLISH → PUBLISHED`, stamps `published_at`, and supersedes the prior published rule for the same `(request_type, band)` identity. A caller can never approve their own submission. **Only published rules route** — the inbox router and the jobs-tier SLA sweep read `PUBLISHED` rules exclusively (ADR 0027 §(a)). Two invariants are contract, not UI: **an overlapping band window against another published rule for the same request type is refused (`409`)**, naming the rule it collides with — the overlap check is service-enforced because band windows are open/half-open and application-interpreted, not a DB exclusion constraint (db 14 §6); and the new version applies to **new requests only** — **in-flight `approval_inbox` rows keep the chain they were routed under**, never a retroactive re-route. The escalation target is fully validated here. Publishing is a config change: audited (`XC-F06`); final approval emits `xc.approval_rule.published` alongside the generic `config.changed` signal every published config on this surface raises. Publication also refuses any referenced catalogue role with no active holder, preventing a stale role/grant assignment from creating a dead route. It **confers no authority** — module service APIs and their own tokens remain the sole authorization authority (ADR 0027 §(d)).\n",
        "tags": [
          "xc",
          "approval-rule"
        ],
        "x-token": "xc.approval_rule.publish",
        "x-realizes-features": [
          "XC-F16"
        ],
        "x-screens": [
          "ADM-S09"
        ],
        "x-touches-entities": [
          "xc.approval_rules",
          "xc.approval_inbox",
          "admin.member_grants",
          "audit.audit_log",
          "xc.outbox"
        ],
        "x-idempotent": true,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": "xc.approval_rule.published",
        "x-rls-scope": "tenant",
        "x-append-only": false,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "$ref": "#/components/parameters/IfMatch"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ApprovalRulePublishInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Rule submitted for peer approval, or peer-approved and published for new requests.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApprovalRule"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "412": {
            "$ref": "#/components/responses/PreconditionFailed"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "423": {
            "$ref": "#/components/responses/Locked"
          },
          "428": {
            "$ref": "#/components/responses/PreconditionRequired"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/search-results": {
      "get": {
        "operationId": "xc.search_result.query",
        "summary": "Cross-module search / command palette query",
        "description": "One search box across people, records and actions (fsd 02 XC-S14; db 14 §7 `xc.search_index`, XC-F13) — a full-text projection updated via `outbox` events, always **re-checked against live RBAC at query time** (`acl_scope` only narrows candidates, db 14 §7 notes). Results are ranked by `search_vector` relevance; the people-directory facet is served separately by `ref→people.org_directory.search_vector` (fsd 02 XC-S14 — not this op). Sort whitelist: `relevance` (default), `title`.\n",
        "tags": [
          "xc",
          "search"
        ],
        "x-token": "xc.search_result.query",
        "x-realizes-features": [
          "XC-F13"
        ],
        "x-screens": [
          "XC-S14"
        ],
        "x-touches-entities": [
          "xc.search_index"
        ],
        "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": "q",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 1
            }
          },
          {
            "name": "entity_kind",
            "in": "query",
            "required": false,
            "description": "Repeatable facet filter.",
            "schema": {
              "type": "array",
              "items": {
                "$ref": "#/components/schemas/SearchEntityKind"
              }
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `relevance` (default), `title`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of ranked search results.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SearchResultPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/job-runs": {
      "get": {
        "operationId": "xc.job_run.list",
        "summary": "List async jobs-tier run history",
        "description": "The XC-F08 observability spine (db 14 §7 `xc.job_runs`) — scheduled/event-driven job executions (payroll runs, leave accruals, statutory batches, expiry scans, outbox/notification dispatch, EWA feed, erasure jobs). **No dedicated ops-console FSD screen exists yet in this round's read set** — an open traceability gap (00 §6), cited against the nearest existing admin surface (`ADM-S01`, the role-aware admin dashboard, 01-org-admin.openapi.yaml) pending a dedicated jobs-monitor screen. Sort whitelist: `started_at`, `-started_at`, `status`.\n",
        "tags": [
          "xc",
          "job-run"
        ],
        "x-token": "xc.job_run.list",
        "x-realizes-features": [
          "XC-F08"
        ],
        "x-screens": [
          "ADM-S01"
        ],
        "x-touches-entities": [
          "xc.job_runs"
        ],
        "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": "job_name",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/JobRunStatus"
            }
          },
          {
            "name": "trigger",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/JobRunTrigger"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `started_at`, `-started_at`, `status`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of job runs.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobRunPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/job-runs/{id}": {
      "get": {
        "operationId": "xc.job_run.get",
        "summary": "Get one job run",
        "description": "Execution detail for a single job run, including `result`/`error` diagnostics (db 14 §7).",
        "tags": [
          "xc",
          "job-run"
        ],
        "x-token": "xc.job_run.get",
        "x-realizes-features": [
          "XC-F08"
        ],
        "x-screens": [
          "ADM-S01"
        ],
        "x-touches-entities": [
          "xc.job_runs"
        ],
        "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"
          }
        ],
        "responses": {
          "200": {
            "description": "The job run.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobRun"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/access-log-entries": {
      "get": {
        "operationId": "audit.access_log.list",
        "summary": "List the privileged-access trail",
        "description": "Login/MFA/permission-decision and privileged-data-access events (db 15 §3 `audit.access_log`, XC-F06/XC-F03/XC-F04) — append-only, time-partitioned, admin-scoped. Hosted here per the keystone's file-composition table (`audit.*` lives in this file; the sibling `audit_log` lens is `admin.audit_log.*` in 01-org-admin.openapi.yaml, ADM-S04, since that lens's screen is org-admin hosted). Cited against the same `ADM-S04` governance console as an additional tab pending its own confirmed IA (00 §6 gap). Sort whitelist: `created_at`, `-created_at`. Default filter window last 30 days.\n",
        "tags": [
          "audit"
        ],
        "x-token": "audit.access_log.list",
        "x-realizes-features": [
          "XC-F06"
        ],
        "x-screens": [
          "ADM-S04"
        ],
        "x-touches-entities": [
          "audit.access_log"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": true,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "date_from",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/TimestampRef"
            }
          },
          {
            "name": "date_to",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/TimestampRef"
            }
          },
          {
            "name": "event",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/AccessLogEvent"
            }
          },
          {
            "name": "outcome",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/AccessLogOutcome"
            }
          },
          {
            "name": "data_class",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/AccessLogDataClass"
            }
          },
          {
            "name": "actor_id",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/UuidRef"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `created_at`, `-created_at`. Default `-created_at`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of access-log entries.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AccessLogEntryPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/access-log-entries/{id}": {
      "get": {
        "operationId": "audit.access_log.get",
        "summary": "Get one access-log entry",
        "description": "Detail row for a single access/permission/data-access event (db 15 §3). The read is itself audited.",
        "tags": [
          "audit"
        ],
        "x-token": "audit.access_log.get",
        "x-realizes-features": [
          "XC-F06"
        ],
        "x-screens": [
          "ADM-S04"
        ],
        "x-touches-entities": [
          "audit.access_log"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": true,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "The access-log entry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AccessLogEntry"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/domain-events": {
      "get": {
        "operationId": "audit.domain_event.list",
        "summary": "List the domain-event journal",
        "description": "The append-only, replayable record of every inter-module event published from `xc.outbox` (db 15 §2 `audit.domain_events`) — the evidence backbone for audit/replay/rebuild and the source an activity feed reads filtered by `(aggregate_type, aggregate_id)` (db 15 §2 notes: \"no dedicated feed table\"). Journal, not a second outbox — no delivery bookkeeping. Sort whitelist: `created_at`, `-created_at`.\n",
        "tags": [
          "audit"
        ],
        "x-token": "audit.domain_event.list",
        "x-realizes-features": [
          "XC-F06"
        ],
        "x-screens": [
          "ADM-S04"
        ],
        "x-touches-entities": [
          "audit.domain_events"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": true,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "aggregate_type",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "aggregate_id",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/UuidRef"
            }
          },
          {
            "name": "event_type",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "date_from",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/TimestampRef"
            }
          },
          {
            "name": "date_to",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/TimestampRef"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `created_at`, `-created_at`. Default `-created_at`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of domain-event journal rows.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DomainEventPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/domain-events/{id}": {
      "get": {
        "operationId": "audit.domain_event.get",
        "summary": "Get one journaled domain event",
        "description": "Detail row, including the full projected-fact `payload` (db 15 §2).",
        "tags": [
          "audit"
        ],
        "x-token": "audit.domain_event.get",
        "x-realizes-features": [
          "XC-F06"
        ],
        "x-screens": [
          "ADM-S04"
        ],
        "x-touches-entities": [
          "audit.domain_events"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": true,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "The domain event.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DomainEvent"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/decision-artifacts": {
      "get": {
        "operationId": "audit.decision_artifact.list",
        "summary": "List the regulated-artifact evidence index",
        "description": "The tamper-evident index of immutable regulated HR artifacts — payslips, Form 16, e-sign signatures, statutory filings, offers, F&F settlements, interview scorecards, letters (db 15 §4 `audit.decision_artifacts`) — indexing evidence whose source-of-truth lives in the owning module but whose immutability/retention is governed here. A re-issue is a new compensating row (no unique on subject); **current = latest `created_at`** for a given `(subject_type, subject_id, artifact_type)`. Sort whitelist: `created_at`, `-created_at`.\n",
        "tags": [
          "audit"
        ],
        "x-token": "audit.decision_artifact.list",
        "x-realizes-features": [
          "XC-F06"
        ],
        "x-screens": [
          "ADM-S04"
        ],
        "x-touches-entities": [
          "audit.decision_artifacts"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": true,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "name": "artifact_type",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/DecisionArtifactType"
            }
          },
          {
            "name": "subject_type",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "subject_id",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/UuidRef"
            }
          },
          {
            "name": "subject_employee_id",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/UuidRef"
            }
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "$ref": "#/components/parameters/PageAfter"
          },
          {
            "$ref": "#/components/parameters/PageBefore"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort whitelist: `created_at`, `-created_at`. Default `-created_at`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of decision-artifact evidence rows.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DecisionArtifactPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/decision-artifacts/{id}": {
      "get": {
        "operationId": "audit.decision_artifact.get",
        "summary": "Get one decision-artifact evidence row",
        "description": "Detail row, with `content_hash` for tamper-evidence verification against the stored blob (db 15 §4).",
        "tags": [
          "audit"
        ],
        "x-token": "audit.decision_artifact.get",
        "x-realizes-features": [
          "XC-F06"
        ],
        "x-screens": [
          "ADM-S04"
        ],
        "x-touches-entities": [
          "audit.decision_artifacts"
        ],
        "x-idempotent": false,
        "x-market": "both",
        "x-sync-async": "sync",
        "x-emits-event": null,
        "x-rls-scope": "tenant",
        "x-append-only": true,
        "x-entitlement": null,
        "x-provisional": null,
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "The decision-artifact evidence row.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DecisionArtifact"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerJWT": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT",
        "description": "Product session token. Two mint paths, one contract (ADR 0010): employees/managers authenticate against Keycloak (mobile + web); workspace staff arrive from the One portal via bridge-token SSO (`POST /api/sso/exchange` verifies the platform's Ed25519 token and mints the product session). The token carries IDENTITY ONLY — `sub`, `tenantUid`, `principal_class`, MFA level, session ref, `exp`. Roles/permissions are re-resolved server-side per request. Enforcement is layered: gateway (TLS/WAF/routing only — NEVER trusted for auth) → NestJS auth guard (validates token, builds the request auth-context) → entitlement middleware (subscription-status → feature-flag → numeric-limit, ADR 0009) → `SET LOCAL app.tenant_id` / `app.user_id` → Postgres FORCED RLS. `tenantUid` is NEVER a path, query, or body parameter.\n"
      },
      "sessionCookie": {
        "type": "apiKey",
        "in": "cookie",
        "name": "groundit_session",
        "description": "The web back-office's product session, carried as an **HttpOnly · Secure · SameSite=Lax** cookie set by `POST /api/v1/auth/login` (and by the bridge-token SSO exchange). The cookie value is an **opaque handle** to an `xc.sessions` row — never a JWT, never a refresh token, and no Keycloak material reaches the browser (security-docs/05 §5, THR-WEB-02/04). It authenticates exactly the same identity-only context `bearerJWT` does, and every layer below (entitlement → RLS GUC) is unchanged; the two schemes differ only in how the credential travels. Mobile uses `bearerJWT`; the browser uses this, because a token readable by JavaScript is a token exfiltrable by XSS. `POST /api/v1/auth/logout` revokes the session row and clears the cookie. The name above is the deployment default (`PLATFORM_SSO_COOKIE_NAME`); clients never read it — the browser attaches it automatically.\n"
      }
    },
    "schemas": {
      "UuidRef": {
        "$ref": "#/components/schemas/Uuid"
      },
      "TimestampRef": {
        "$ref": "#/components/schemas/Timestamp"
      },
      "DateOnlyRef": {
        "$ref": "#/components/schemas/DateOnly"
      },
      "DecimalHoursRef": {
        "$ref": "#/components/schemas/DecimalHours"
      },
      "LocalizedTextRef": {
        "$ref": "#/components/schemas/LocalizedText"
      },
      "FileDownloadRef": {
        "$ref": "#/components/schemas/FileDownload"
      },
      "AuditMetaRef": {
        "$ref": "#/components/schemas/AuditMeta"
      },
      "CursorPageRef": {
        "$ref": "#/components/schemas/CursorPage"
      },
      "NotificationChannel": {
        "type": "string",
        "enum": [
          "PUSH",
          "IN_APP",
          "SSE"
        ]
      },
      "NotificationStatus": {
        "type": "string",
        "enum": [
          "QUEUED",
          "SENT",
          "DELIVERED",
          "READ",
          "FAILED"
        ]
      },
      "NotificationCategory": {
        "type": "string",
        "description": "Parent `notification_templates.category` (db 14 §3).",
        "enum": [
          "WORKFLOW",
          "APPROVAL",
          "REMINDER",
          "OTP",
          "ANNOUNCEMENT",
          "ALERT"
        ]
      },
      "Notification": {
        "description": "xc.notifications — a sent notification instance (db 14 §3). Partitioned RANGE by created_at.",
        "type": "object",
        "required": [
          "id",
          "channel",
          "status",
          "created_at"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "template_id": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/UuidRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "outbox_event_id": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/UuidRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "subject_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "Polymorphic source module entity, e.g. `leave.leave_applications`."
          },
          "subject_id": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/UuidRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "channel": {
            "$ref": "#/components/schemas/NotificationChannel"
          },
          "category": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/NotificationCategory"
              },
              {
                "type": "null"
              }
            ],
            "description": "Resolved via `template_id` join; null for ad-hoc/template-less rows."
          },
          "locale": {
            "type": "string",
            "enum": [
              "en",
              "ar"
            ]
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "body": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "$ref": "#/components/schemas/NotificationStatus"
          },
          "sent_at": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/TimestampRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "read_at": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/TimestampRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "created_at": {
            "$ref": "#/components/schemas/TimestampRef"
          }
        }
      },
      "NotificationPage": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CursorPageRef"
          },
          {
            "type": "object",
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Notification"
                }
              }
            }
          }
        ]
      },
      "MarkAllReadResult": {
        "type": "object",
        "required": [
          "updated_count"
        ],
        "additionalProperties": false,
        "properties": {
          "updated_count": {
            "type": "integer",
            "minimum": 0
          }
        }
      },
      "DeviceTokenRegisterInput": {
        "type": "object",
        "required": [
          "fcm_token"
        ],
        "additionalProperties": false,
        "description": "Merged into `xc.sessions.device_info` (db 14 §1 JSONB payload shape).",
        "properties": {
          "fcm_token": {
            "type": "string",
            "minLength": 1
          },
          "os": {
            "type": "string"
          },
          "os_version": {
            "type": "string"
          },
          "app_version": {
            "type": "string"
          },
          "device_model": {
            "type": "string"
          }
        }
      },
      "DeviceTokenRegisterResult": {
        "type": "object",
        "required": [
          "session_id",
          "registered_at"
        ],
        "additionalProperties": false,
        "properties": {
          "session_id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "fcm_token": {
            "type": "string"
          },
          "registered_at": {
            "$ref": "#/components/schemas/TimestampRef"
          }
        }
      },
      "PushSubscriptionRegisterInput": {
        "type": "object",
        "required": [
          "endpoint",
          "p256dh",
          "auth"
        ],
        "additionalProperties": false,
        "description": "The browser's `PushSubscription`. SECRET: these three values together are the capability to display a notification on the caller's device. Never logged, never echoed back.\n",
        "properties": {
          "endpoint": {
            "type": "string",
            "format": "uri",
            "minLength": 12,
            "maxLength": 512,
            "pattern": "^https://",
            "description": "The push service URL for this subscription, verbatim from `PushSubscription.toJSON()`. The host must belong to a recognised browser push service — the server holds the allow-list and answers `422` for anything else, because this URL is one a background worker later opens a socket to. Never rewrite or proxy it.\n"
          },
          "p256dh": {
            "type": "string",
            "minLength": 80,
            "maxLength": 255,
            "description": "base64url P-256 ECDH public key (`keys.p256dh`)."
          },
          "auth": {
            "type": "string",
            "minLength": 16,
            "maxLength": 64,
            "description": "base64url auth secret (`keys.auth`)."
          },
          "user_agent": {
            "type": "string",
            "maxLength": 256,
            "description": "Optional device label, kept only so a person can recognise their own devices. Not used for routing and never rendered to anyone else.\n"
          }
        }
      },
      "PushSubscriptionRegisterResult": {
        "type": "object",
        "required": [
          "id",
          "subscribed_at"
        ],
        "additionalProperties": false,
        "description": "Deliberately carries no subscription material — a client that just sent the endpoint does not need it echoed, and an echo is one log line away from a leak.\n",
        "properties": {
          "id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "subscribed_at": {
            "$ref": "#/components/schemas/TimestampRef"
          }
        }
      },
      "PushSubscriptionRevokeInput": {
        "type": "object",
        "required": [
          "endpoint"
        ],
        "additionalProperties": false,
        "description": "In a body rather than a query parameter on purpose: an endpoint is a capability URL and a query string is written to every access log between the client and the handler.\n",
        "properties": {
          "endpoint": {
            "type": "string",
            "format": "uri",
            "minLength": 12,
            "maxLength": 512,
            "pattern": "^https://"
          }
        }
      },
      "SessionLogoutResult": {
        "type": "object",
        "required": [
          "session_id",
          "status"
        ],
        "additionalProperties": false,
        "properties": {
          "session_id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "status": {
            "type": "string",
            "enum": [
              "REVOKED"
            ]
          },
          "revoked_at": {
            "$ref": "#/components/schemas/TimestampRef"
          }
        }
      },
      "AuthIdentifyInput": {
        "type": "object",
        "required": [
          "email"
        ],
        "additionalProperties": false,
        "properties": {
          "email": {
            "type": "string",
            "pattern": "^(?:[^\\s@]+@[^\\s@]+\\.[^\\s@]+|\\+[1-9][0-9]{7,14})$",
            "maxLength": 320,
            "description": "Email or E.164 phone (legacy field name retained). Phone resolves to one realm username on the server; unknown or ambiguous phones receive PASSWORD at identify and uniform 401 at login."
          },
          "workspace": {
            "type": "string",
            "maxLength": 64,
            "description": "The workspace segment the sign-in is being attempted from. **Optional, and it never changes the shape of the answer** — it enables the lazy ESS provisioning arm described on the operation. An unknown, malformed or cancelled slug is simply not provisioned against; it is never a distinguishable error, because a caller must not be able to probe which slugs exist beyond what `xc.workspace.resolve` already discloses.\n"
          },
          "market": {
            "type": "string",
            "enum": [
              "IN",
              "KSA"
            ],
            "description": "Narrows the probe to one realm. **Optional and normally absent** — omitted, every provisioned realm is probed in parallel, for the same anti-region-oracle reason `LoginInput.market` states.\n"
          }
        }
      },
      "AuthIdentifyResult": {
        "type": "object",
        "required": [
          "next"
        ],
        "additionalProperties": false,
        "properties": {
          "next": {
            "type": "string",
            "enum": [
              "PASSWORD",
              "SETUP"
            ],
            "description": "`PASSWORD` — collect a password and post `xc.auth.login`. This is also the answer for an address no identity provider knows and for a Sysmedac One back-office member, which is what keeps the route non-enumerating. `SETUP` — a realm user exists and holds no password: run the first-login flow (`xc.auth.mobile.password.forgot`, verify, reset) and then sign in. Copy on both surfaces must stay conditional — \"we sent a code to that address if it has a GroundIT account\" — so the screen itself never confirms existence either.\n"
          }
        }
      },
      "LoginInput": {
        "type": "object",
        "required": [
          "email",
          "password"
        ],
        "additionalProperties": false,
        "properties": {
          "email": {
            "type": "string",
            "pattern": "^(?:[^\\s@]+@[^\\s@]+\\.[^\\s@]+|\\+[1-9][0-9]{7,14})$",
            "maxLength": 320,
            "description": "Email or E.164 phone (legacy field name retained). Phone resolves to one realm username on the server; unknown or ambiguous phones receive PASSWORD at identify and uniform 401 at login."
          },
          "password": {
            "type": "string",
            "minLength": 1,
            "maxLength": 256,
            "description": "Verified at the market's Keycloak realm. No complexity/length policy is asserted here beyond a transport ceiling — that policy is the IdP's, and restating it would leak it to an unauthenticated caller.\n"
          },
          "invitation_token": {
            "type": "string",
            "maxLength": 128,
            "description": "A cross-tenant workspace invitation, `{selector}.{verifier}`, copied verbatim from the emailed link (issue #348, ADR 0061). **Optional and normally absent** — sent only on the one sign-in that redeems an invitation, and never held onto afterwards: redemption is single-use and happens on this call.\n\n**When present it DECIDES the workspace.** The tenant and the `xc.identities` row are read from the invitation row that pinned them at issue time, never from this request, so the candidate set is bypassed rather than joined and any `workspace` sent alongside is ignored — the invited workspace must not be cross-checked against whatever page the link happened to land on. It is the one input that can bind a subject into a tenant its own token does not name, and the credential must still verify at the IdP first: the invitation proves mailbox control, not identity, and both are required.\n\nThe ceiling is a shape gate only. Every failure class — unknown selector, wrong verifier, expired, revoked, already spent, or a credential that authenticates in more than one realm — is the same uniform `401` as a bad password, so neither this bound nor the response is an oracle (ADR 0061 §b).\n"
          },
          "market": {
            "type": "string",
            "enum": [
              "IN",
              "KSA"
            ],
            "description": "Which realm authenticates this login (`groundit-in` / `groundit-sa`, ADR 0010 §a). **Optional since ADR 0022** — omitted, every provisioned realm is attempted in parallel and the candidate sets are unioned. There is deliberately no default: defaulting to `IN` would have made a KSA account's sign-in depend on the client remembering to say so.\n"
          },
          "workspace": {
            "type": "string",
            "maxLength": 64,
            "description": "The workspace segment the attempt was made from. When present it MUST be among the verified subject's candidate workspaces; a mismatch is the uniform `401`. Absent for the unbranded `/login` entry, where a single candidate signs straight in and several answer `SELECT_WORKSPACE`.\n"
          },
          "portal": {
            "type": "string",
            "enum": [
              "WORKSPACE_MEMBER",
              "EMPLOYEE"
            ],
            "description": "Which portal to open, answering a `CHOOSE_PORTAL` response (issue #802). Sent only on the second round of a dual-hat sign-in; absent on every first attempt and absent forever for the single-persona identities that never see the question.\n\n**It names a destination, not an identity.** The server re-verifies from `xc.identities` and `people.employee_subjects`, in the tenant the credential resolved to, that this identity genuinely holds the persona being asked for, and the value becomes a session pin that can only ever NARROW: `EMPLOYEE` is honoured only against a proven employee link, and `WORKSPACE_MEMBER` confers nothing on its own because the derived rule still demands a real `platform_member_id`. Sending either value from an identity that does not hold it changes nothing about the session that gets minted. On a single-persona sign-in the field is ignored rather than rejected — a stale form field must not become a sign-in failure.\n"
          }
        }
      },
      "LoginResult": {
        "type": "object",
        "required": [
          "result",
          "expiresAt",
          "session"
        ],
        "additionalProperties": false,
        "properties": {
          "result": {
            "type": "string",
            "const": "SIGNED_IN",
            "description": "Discriminator. A session exists and the cookie carries it."
          },
          "expiresAt": {
            "allOf": [
              {
                "$ref": "#/components/schemas/TimestampRef"
              }
            ],
            "description": "When the minted session lapses. The credential itself is the cookie, never this body."
          },
          "session": {
            "$ref": "#/components/schemas/SessionProjection"
          }
        }
      },
      "WorkspaceSelectionRequired": {
        "type": "object",
        "description": "The credential verified but named more than one workspace, and the request chose none (ADR 0022). **Nothing was minted** — no cookie, no bearer token, no `xc.sessions` row — so this response is not a product session and confers no product access. Password login re-posts the SAME credentials plus `workspace`. OTP login instead includes a five-minute, one-use `selectionToken`, because the six-digit code is consumed exactly once; the ticket maps only to transient verified subject claims in Redis and contains no OTP, IdP token, password or product-session handle.\n\nCandidates are resolved from the **verified token** — the proven `sub` across all tenants, plus the verified `email` claim for a workspace this subject has not signed in to yet, **restricted to the tenant the token itself names** (migration 0057, db 14 §2). Both arms sit BEHIND the password, which is what separates this from the anonymous pre-auth lookup ADR 0022 deleted, and the pin on the email arm is what stops a renamed address adopting a stranger's unclaimed identity row.\n\n**Not yet reachable in practice:** nothing binds a `sub` outside the token's own tenant today, so every live caller resolves to exactly one candidate and receives `SIGNED_IN`. Clients must still implement this branch — see ADR 0022 § Consequences and issue #348.\n",
        "required": [
          "result",
          "workspaces"
        ],
        "additionalProperties": false,
        "properties": {
          "result": {
            "type": "string",
            "const": "SELECT_WORKSPACE",
            "description": "Discriminator. No session was minted; choose a workspace and re-post."
          },
          "workspaces": {
            "type": "array",
            "minItems": 2,
            "maxItems": 10,
            "description": "The workspaces this subject can hold a session in — suspended/deactivated identities and cancelled workspaces are already excluded, so the list never offers a choice that would fail on selection. Never fewer than two: one candidate signs in rather than asking.\n",
            "items": {
              "$ref": "#/components/schemas/WorkspaceChoice"
            }
          },
          "selectionToken": {
            "type": "string",
            "minLength": 20,
            "maxLength": 2048,
            "description": "Present only on OTP login; submit once with `workspace` instead of reusing the OTP."
          },
          "selectionExpiresIn": {
            "type": "integer",
            "minimum": 1,
            "maximum": 300,
            "description": "Present only with `selectionToken`; lifetime in seconds."
          }
        }
      },
      "PortalSelectionRequired": {
        "type": "object",
        "description": "The credential verified and resolved a single workspace, but the identity holds **both** personas there — a workspace-member binding AND a linked employee — so which portal to open is still open (issue #802, ADR 0039 amendment). **Nothing was minted**: no cookie, no `xc.sessions` row. This response is a question, not a credential.\n\nThe mirror of `SELECT_WORKSPACE`, and stateless for the same reason: no selection ticket is minted, so there is nothing to expire, leak, or replay into a session. The caller re-posts the SAME credentials plus its chosen `portal`, and the second round re-verifies the identity link from scratch before pinning anything.\n\n**One credential per human is the point.** The other persona is reached through the server-side identity link (`people.employee_subjects` / `xc.identities.platform_member_id`), never through a second password. A single-persona identity — the overwhelming majority — never receives this response and never sees a chooser.\n",
        "required": [
          "result",
          "portals"
        ],
        "additionalProperties": false,
        "properties": {
          "result": {
            "type": "string",
            "const": "CHOOSE_PORTAL",
            "description": "Discriminator. No session was minted; choose a portal and re-post."
          },
          "portals": {
            "type": "array",
            "minItems": 2,
            "maxItems": 2,
            "description": "The two doors that exist for this person. Always exactly two — a choice of one would have been signed straight in. Deliberately carries the class and NOTHING else: this is a pre-session, unauthenticated-`200` surface, so it discloses no employee id, no member id, no role list and no name.\n",
            "items": {
              "$ref": "#/components/schemas/PortalChoice"
            }
          }
        }
      },
      "SwitchPrincipalInput": {
        "type": "object",
        "required": [
          "to"
        ],
        "additionalProperties": false,
        "properties": {
          "to": {
            "type": "string",
            "enum": [
              "WORKSPACE_MEMBER",
              "EMPLOYEE"
            ],
            "description": "The principal class to re-mint as. The session cookie says WHO is asking; this says only WHERE they want to go. Naming a class is not being granted it — see the operation description for the server-side link check, which runs again inside the writing transaction.\n"
          }
        }
      },
      "SwitchPrincipalResult": {
        "type": "object",
        "required": [
          "result",
          "principalClass",
          "redirectTo",
          "expiresAt",
          "session"
        ],
        "additionalProperties": false,
        "properties": {
          "result": {
            "type": "string",
            "const": "SWITCHED"
          },
          "principalClass": {
            "type": "string",
            "enum": [
              "WORKSPACE_MEMBER",
              "EMPLOYEE"
            ],
            "description": "The class the NEW session carries. Always equal to the requested `to`."
          },
          "redirectTo": {
            "type": "string",
            "description": "`/me` or `/home` — path-relative and workspace-free on purpose. The web tier owns `/{workspace}/…` composition, and an absolute URL minted here would be a second place that knows how GroundIT's URLs are shaped.\n"
          },
          "expiresAt": {
            "allOf": [
              {
                "$ref": "#/components/schemas/TimestampRef"
              }
            ],
            "description": "When the NEW session lapses. The credential itself is the cookie, never this body."
          },
          "session": {
            "$ref": "#/components/schemas/SessionProjection"
          }
        }
      },
      "PortalChoice": {
        "type": "object",
        "required": [
          "principalClass"
        ],
        "additionalProperties": false,
        "properties": {
          "principalClass": {
            "type": "string",
            "enum": [
              "WORKSPACE_MEMBER",
              "EMPLOYEE"
            ],
            "description": "`WORKSPACE_MEMBER` opens the back office (`/home`); `EMPLOYEE` opens employee self-service (`/me`, ADR 0039). Clients should label these in product terms — \"Management\" and \"Employee\" — rather than echoing the class name at the user.\n"
          }
        }
      },
      "WorkspaceChoice": {
        "description": "One entry in the sign-in picker: the public workspace facts, plus **how this subject relates to it**.\n",
        "allOf": [
          {
            "$ref": "#/components/schemas/PublicWorkspace"
          },
          {
            "type": "object",
            "required": [
              "membership"
            ],
            "properties": {
              "membership": {
                "type": "string",
                "enum": [
                  "MEMBER",
                  "INVITED"
                ],
                "description": "`MEMBER` — this subject's `keycloak_sub` is already bound here; they have signed in before. `INVITED` — this workspace provisioned their address but they have never signed in, so choosing it BINDS the subject on first use.\n\n**Clients MUST render the two distinguishably** (migration 0057, ADR 0022). This is not cosmetic: a tenant can provision an arbitrary address, and an address is not proof that the person controls it — which is exactly why the `INVITED` arm is pinned to the tenant the verified token already names and can never surface a stranger's workspace. Presenting an unfamiliar `INVITED` workspace as an existing membership would still be a plausible phishing surface. An `INVITED` entry is an *offer to join*.\n"
              }
            }
          }
        ]
      },
      "TenantCacheStatus": {
        "type": "string",
        "description": "`xc.tenant_cache.status` as the portal sees it (ADR 0009 §e).",
        "enum": [
          "ACTIVE",
          "PAST_DUE",
          "SUSPENDED",
          "CANCELLED"
        ]
      },
      "SessionProjection": {
        "type": "object",
        "description": "Per-request projection over `xc.identities` + `admin.member_grants` + `xc.tenant_cache` — the contract of record for the portal shell (api-docs 02 §6).\n",
        "required": [
          "tenantUid",
          "identityId",
          "principalClass",
          "employeeId",
          "switchTarget",
          "displayName",
          "email",
          "roles",
          "grants",
          "isImpersonation",
          "workspace"
        ],
        "additionalProperties": false,
        "properties": {
          "tenantUid": {
            "allOf": [
              {
                "$ref": "#/components/schemas/UuidRef"
              }
            ],
            "description": "The verified session's tenant — never a request parameter (02 §3)."
          },
          "identityId": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "principalClass": {
            "type": "string",
            "description": "The closed principal-class set the verified session carries. Derived server-side from the session's `channel` and the identity's platform-member binding, never from anything a client sends: a WEB session is `WORKSPACE_MEMBER` when the identity holds a member binding and `EMPLOYEE` when it does not (the ESS portal, ADR 0039); the mobile rail is always `EMPLOYEE`, even for an identity that is also a member. The class is the hat, not the authority: an EMPLOYEE session that also has a live management-portal seat includes that seat's `roles`/`grants` (#1830), so a portal admin is not projected as a plain employee. A WORKSPACE_MEMBER session still does not pick up EMPLOYEE-keyed grants. The remaining values exist for completeness — `PLATFORM`/`PARTNER` principals do not render this shell.\n",
            "enum": [
              "EMPLOYEE",
              "WORKSPACE_MEMBER",
              "APPLICANT",
              "ALUMNI",
              "PLATFORM",
              "PARTNER"
            ]
          },
          "employeeId": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/UuidRef"
              },
              {
                "type": "null"
              }
            ],
            "description": "The employee this principal acts as, via `people.employee_subjects`; null for a pure workspace member."
          },
          "switchTarget": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "WORKSPACE_MEMBER",
              "EMPLOYEE",
              null
            ],
            "description": "The OTHER portal this human may switch into, or `null` when there is no second one (issue #802). Non-null exactly when this identity carries BOTH a workspace-member binding and a verified `people.employee_subjects` (`KEYCLOAK_SUB`) employee link — the same two facts `xc.auth.switch_principal` re-checks before it mints anything.\n\n**This is the only thing a client may render a \"switch portal\" affordance from.** It is a server fact, not a client-side inference from `principalClass`; hiding the entry when it is null is a courtesy, not the control, because posting the switch without it still fails the server-side check. Always `null` on the mobile rail: ADR 0021 pins `MOBILE ⇒ EMPLOYEE`, so advertising the switch there would offer a door the API refuses.\n"
          },
          "displayName": {
            "type": "string",
            "description": "`people.employees.full_name` read at SELF scope, falling back to the identity's email and then to a neutral placeholder — the projection never widens its own scope to find a name.\n"
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email"
          },
          "roles": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "`admin.roles.role_key`s held, e.g. `TENANT_ADMIN`. Re-resolved every request. Presentation only — clients gate on `grants`. An EMPLOYEE session includes the live management-portal seat's role keys when one exists (#1830).\n"
          },
          "grants": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Permission tokens held, e.g. `admin.role.list`. Drives every permission-gated surface in the shell. Advisory for rendering only — the server re-checks each token at the point of use. Same union as authorization (#1830): an EMPLOYEE session includes the live management-portal seat's tokens.\n"
          },
          "isImpersonation": {
            "type": "boolean",
            "description": "True for a session minted by `platform.tenant.start_impersonation` (`xc.sessions.impersonated_by IS NOT NULL`). Only the boolean crosses the wire; the acting super-admin identity stays in the audit plane, never in a browser.\n"
          },
          "workspace": {
            "type": "object",
            "required": [
              "slug",
              "name",
              "brandColor",
              "status",
              "features",
              "locale"
            ],
            "additionalProperties": false,
            "properties": {
              "slug": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The platform `workspace_slug` — the tenant segment in `/{workspace}/…` URLs."
              },
              "name": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "`admin.tenant_config` key `branding.company_name`."
              },
              "brandColor": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "`admin.tenant_config` key `branding.primary_color`."
              },
              "status": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/TenantCacheStatus"
                  }
                ],
                "description": "A mirror of the value the entitlement guard enforces on, not a gate. A missing cache row projects as `SUSPENDED` — the projection never contradicts a fail-closed guard.\n"
              },
              "features": {
                "type": "object",
                "additionalProperties": {
                  "type": "boolean"
                },
                "description": "The plan's module flags (`xc.tenant_cache.features`, from `/platform/tenants/provision` and `PATCH /platform/tenants/{tenantUid}/plan`). A flag that is explicitly `false` marks the module \"coming soon\" in the shell — badged rail item, placeholder page. Absent keys assert nothing (most tenants carry `{}`), so the shell gates on `false` only. Advisory for rendering; `RequiresFeature` on the API is the enforcement.\n"
              },
              "locale": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/TenantLocaleFacts"
                  }
                ],
                "description": "The tenant's own Arabic opt-in, from `admin.tenant_config` keys `locale.default` / `locale.supported` / `locale.rtl_locales` (db 02 §2.4). Arabic is strictly opt-in per tenant (ADR 0005 config/packs; ADR 0012 keeps the RTL capability) — a tenant with no locale config rows (the common case straight out of provisioning, which only writes branding) projects the English-only default here, never a value negotiated from the caller's `Accept-Language`. This is the one low-privilege channel every principal reads, so an employee's locale gate matches an admin's exactly.\n"
              }
            }
          }
        }
      },
      "TenantLocaleFacts": {
        "type": "object",
        "description": "The effective per-tenant locale opt-in every session projects — the source `resolveLocaleFromRequest` (api-docs 04 frontend architecture) filters its candidates against.\n",
        "required": [
          "default",
          "supported",
          "rtl"
        ],
        "additionalProperties": false,
        "properties": {
          "default": {
            "$ref": "#/components/schemas/LocaleCode"
          },
          "supported": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/LocaleCode"
            },
            "description": "Never empty — defaults to `[en-IN]` when the tenant has not configured `locale.supported`."
          },
          "rtl": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/LocaleCode"
            },
            "description": "Subset of `supported` that lays out right-to-left; empty until the tenant opts Arabic in."
          }
        }
      },
      "LocaleCode": {
        "type": "string",
        "description": "The closed locale set the web/mobile shells render (ADR 0012).",
        "enum": [
          "en-IN",
          "ar-SA"
        ]
      },
      "PublicWorkspace": {
        "type": "object",
        "description": "The minimum a login page can render, and nothing more (enumeration-resistant, 02 §4).",
        "required": [
          "slug",
          "name",
          "brandColor"
        ],
        "additionalProperties": false,
        "properties": {
          "slug": {
            "type": "string",
            "description": "The normalized (lowercased) slug that resolved."
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "`admin.tenant_config` key `branding.company_name`."
          },
          "brandColor": {
            "type": [
              "string",
              "null"
            ],
            "description": "`admin.tenant_config` key `branding.primary_color`."
          }
        }
      },
      "MobileDeviceInfo": {
        "type": "object",
        "description": "Handset facts recorded on `xc.sessions.device_info` so a user reviewing their active sessions sees a device they recognise. **Deliberately non-identifying** (security-docs/04): no IMEI, no advertising id, no hardware serial — a stable hardware identifier is personal data under DPDP/PDPL and would make this a tracking surface. Unknown properties are rejected (`422`), not silently stored.\n",
        "additionalProperties": false,
        "properties": {
          "platform": {
            "type": "string",
            "maxLength": 64,
            "description": "e.g. `android`, `ios`."
          },
          "osVersion": {
            "type": "string",
            "maxLength": 64
          },
          "model": {
            "type": "string",
            "maxLength": 64
          },
          "appVersion": {
            "type": "string",
            "maxLength": 32
          },
          "installId": {
            "type": "string",
            "maxLength": 128,
            "description": "An app-generated, app-scoped install identifier. A reinstall produces a new one — that is the point: it names a session's device for revocation, not a person across installs.\n"
          }
        }
      },
      "MobileLoginInput": {
        "type": "object",
        "required": [
          "email",
          "password"
        ],
        "additionalProperties": false,
        "description": "**No `invitation_token` — and its absence is the contract, not an omission.** The web rail's `LoginInput` accepts a cross-tenant workspace invitation (issue #348, ADR 0061); the mobile controller has no redemption path at all, so a handset sending the field gets a `422` from `forbidNonWhitelisted` rather than a session. **Known limitation:** an invited user must redeem their emailed link once on the web rail; from the next sign-in onward the binding exists and mobile sees that workspace through the ordinary `SELECT_WORKSPACE` picker. Adding the field here is a server change, not a spec change, and must not be anticipated in this document.\n",
        "properties": {
          "email": {
            "type": "string",
            "pattern": "^(?:[^\\s@]+@[^\\s@]+\\.[^\\s@]+|\\+[1-9][0-9]{7,14})$",
            "maxLength": 320,
            "description": "Email or E.164 phone (legacy field name retained). Phone resolves to one realm username on the server; unknown or ambiguous phones receive PASSWORD at identify and uniform 401 at login."
          },
          "password": {
            "type": "string",
            "minLength": 1,
            "maxLength": 256,
            "description": "Verified at the market's Keycloak realm; no policy is restated here (see `LoginInput`)."
          },
          "market": {
            "type": "string",
            "enum": [
              "IN",
              "KSA"
            ],
            "description": "Which realm authenticates this login (`groundit-in` / `groundit-sa`, ADR 0010 §a). Optional (ADR 0022): omitted, every provisioned realm is attempted in parallel, so the app need not ask a new user which country they are in before it can ask for their password.\n"
          },
          "workspace": {
            "type": "string",
            "maxLength": 64,
            "description": "The slug chosen from a prior `SELECT_WORKSPACE` response (or typed as a company code). When present it MUST be among the verified subject's candidates; a mismatch is the uniform `401`.\n"
          },
          "device": {
            "$ref": "#/components/schemas/MobileDeviceInfo"
          },
          "rememberDevice": {
            "$ref": "#/components/schemas/MobileRememberDevice"
          }
        }
      },
      "MobileRememberDevice": {
        "type": "boolean",
        "description": "Ask for an ADR 0057 refresh credential alongside the session, so this handset stays signed in past the session TTL for up to `AUTH_REFRESH_TTL_SECONDS` (default 30 days). **Opt-in, and the opt-in is the control**: a refresh token is a month-long credential, so minting one for every sign-in would hand a month of access to a borrowed handset that asked for a single shift. Absent or `false` is byte-identical to the pre-#961 contract — a session token, an expiry, a projection, nothing else. Only the client can know whether this is the user's own device, which is why it is a request field; asking is not receiving, though — the credential is minted server-side, MOBILE rail only, and the web password rail has no such field (ADR 0057 §d).\n"
      },
      "MobileRefreshInput": {
        "type": "object",
        "required": [
          "refreshToken"
        ],
        "additionalProperties": false,
        "properties": {
          "refreshToken": {
            "type": "string",
            "minLength": 40,
            "maxLength": 256,
            "description": "The refresh credential returned by a `rememberDevice` sign-in or a previous rotation. **Opaque** — never parse, split, or decode it. Single-use: the call that accepts it also spends it. Send it in this body and **never** in `Authorization`; it is not a session handle and authenticates nothing if presented as one.\n"
          }
        }
      },
      "MobileOtpRequestInput": {
        "type": "object",
        "required": [
          "identifier"
        ],
        "additionalProperties": false,
        "properties": {
          "identifier": {
            "type": "string",
            "maxLength": 320,
            "pattern": "^(?:[^\\\\s@]+@[^\\\\s@]+\\\\.[^\\\\s@]+|\\\\+[1-9]\\\\d{7,14})$",
            "description": "Email address or E.164 mobile number. Email is normalized to lowercase."
          },
          "market": {
            "type": "string",
            "enum": [
              "IN",
              "KSA"
            ],
            "description": "Optional realm hint; omit to probe every provisioned realm concurrently."
          }
        }
      },
      "MobileOtpChallenge": {
        "type": "object",
        "required": [
          "challenge",
          "expiresIn",
          "resendAfter"
        ],
        "additionalProperties": false,
        "properties": {
          "challenge": {
            "type": "string",
            "minLength": 20,
            "maxLength": 2048,
            "description": "Opaque Keycloak handle. It contains no OTP and must not be parsed or logged."
          },
          "expiresIn": {
            "type": "integer",
            "minimum": 1,
            "maximum": 3600,
            "description": "Challenge lifetime in seconds."
          },
          "resendAfter": {
            "type": "integer",
            "minimum": 1,
            "maximum": 3600,
            "description": "Seconds before resend is allowed."
          }
        }
      },
      "MobileOtpVerifyInput": {
        "type": "object",
        "additionalProperties": false,
        "oneOf": [
          {
            "required": [
              "challenge",
              "code"
            ],
            "not": {
              "required": [
                "selectionToken"
              ]
            }
          },
          {
            "required": [
              "selectionToken",
              "workspace"
            ],
            "not": {
              "anyOf": [
                {
                  "required": [
                    "challenge"
                  ]
                },
                {
                  "required": [
                    "code"
                  ]
                }
              ]
            }
          }
        ],
        "properties": {
          "challenge": {
            "type": "string",
            "minLength": 20,
            "maxLength": 2048
          },
          "code": {
            "type": "string",
            "pattern": "^\\\\d{6}$",
            "description": "Exactly six decimal digits."
          },
          "selectionToken": {
            "type": "string",
            "minLength": 20,
            "maxLength": 2048,
            "description": "One-use ticket returned with OTP `SELECT_WORKSPACE`; mutually exclusive with challenge/code."
          },
          "market": {
            "type": "string",
            "enum": [
              "IN",
              "KSA"
            ]
          },
          "workspace": {
            "type": "string",
            "maxLength": 64,
            "description": "Chosen slug after `SELECT_WORKSPACE`; omit on the first verification attempt."
          },
          "device": {
            "$ref": "#/components/schemas/MobileDeviceInfo"
          },
          "rememberDevice": {
            "$ref": "#/components/schemas/MobileRememberDevice"
          }
        }
      },
      "MobilePasswordResetVerifyInput": {
        "type": "object",
        "required": [
          "challenge",
          "code"
        ],
        "additionalProperties": false,
        "properties": {
          "challenge": {
            "type": "string",
            "minLength": 20,
            "maxLength": 2048
          },
          "code": {
            "type": "string",
            "pattern": "^\\\\d{6}$"
          },
          "market": {
            "type": "string",
            "enum": [
              "IN",
              "KSA"
            ]
          }
        }
      },
      "MobilePasswordResetChallenge": {
        "type": "object",
        "required": [
          "resetToken",
          "expiresIn"
        ],
        "additionalProperties": false,
        "properties": {
          "resetToken": {
            "type": "string",
            "minLength": 20,
            "maxLength": 2048,
            "description": "Short-lived, one-use Keycloak reset handle; not an API session credential."
          },
          "expiresIn": {
            "type": "integer",
            "minimum": 1,
            "maximum": 3600
          }
        }
      },
      "MobilePasswordResetInput": {
        "type": "object",
        "required": [
          "resetToken",
          "newPassword",
          "confirmPassword"
        ],
        "additionalProperties": false,
        "properties": {
          "resetToken": {
            "type": "string",
            "minLength": 20,
            "maxLength": 2048
          },
          "newPassword": {
            "type": "string",
            "minLength": 1,
            "maxLength": 256,
            "description": "Strength is evaluated by the active Keycloak realm policy."
          },
          "confirmPassword": {
            "type": "string",
            "minLength": 1,
            "maxLength": 256
          },
          "market": {
            "type": "string",
            "enum": [
              "IN",
              "KSA"
            ]
          }
        }
      },
      "MobileLoginResult": {
        "type": "object",
        "required": [
          "result",
          "token",
          "expiresAt",
          "session"
        ],
        "additionalProperties": false,
        "properties": {
          "result": {
            "type": "string",
            "const": "SIGNED_IN",
            "description": "Discriminator — the mobile counterpart of `LoginResult.result`. A `200` may instead carry `WorkspaceSelectionRequired`, so branch on this before reading `token`.\n"
          },
          "token": {
            "type": "string",
            "description": "The session credential — the same opaque `<tenantUid>.<tokenRef>` handle the portal keeps in a cookie. **Opaque:** never parse, split, or decode it. Store in platform secure storage and send as `Authorization: Bearer <token>`. Not a JWT, carries no claims, and is revoked server-side the moment the session row is.\n"
          },
          "expiresAt": {
            "allOf": [
              {
                "$ref": "#/components/schemas/TimestampRef"
              }
            ],
            "description": "When the handle lapses. At (or before, on any `401`) this instant the app either signs in again or — when it holds a refresh credential — rotates at `POST /auth/mobile/refresh`.\n"
          },
          "session": {
            "$ref": "#/components/schemas/SessionProjection"
          },
          "refreshToken": {
            "type": "string",
            "description": "The ADR 0057 refresh credential. **Present only when the request set `rememberDevice: true`**, and absent (not null) otherwise — a client that never asks sees the pre-#961 body unchanged. Handed over ONCE and unrecoverable: only its SHA-256 is stored. A PWA proxies it into an `HttpOnly; Secure; SameSite=Lax` cookie and never lets JavaScript hold it (ADR 0057 §c); a native client uses Keychain / Keystore. Never `localStorage`, never a log line.\n"
          },
          "refreshExpiresAt": {
            "allOf": [
              {
                "$ref": "#/components/schemas/TimestampRef"
              }
            ],
            "description": "When the refresh credential itself lapses. Slides forward on every rotation. Present exactly when `refreshToken` is.\n"
          }
        }
      },
      "MobileRefreshResult": {
        "description": "What `xc.auth.mobile_refresh` answers — the `MobileLoginResult` members with the two refresh members REQUIRED, so the app can reuse one \"I now have a session\" code path for sign-in and rotation alike. Persist `token` and `refreshToken` together, atomically: the previous pair is dead by the time this response is written.\n",
        "allOf": [
          {
            "$ref": "#/components/schemas/MobileLoginResult"
          },
          {
            "type": "object",
            "required": [
              "refreshToken",
              "refreshExpiresAt"
            ]
          }
        ]
      },
      "FileLifecycleTier": {
        "type": "string",
        "enum": [
          "TEMPORARY",
          "RETAINED"
        ]
      },
      "FileUploadUrlRequest": {
        "type": "object",
        "required": [
          "doc_type",
          "content_type",
          "file_name"
        ],
        "additionalProperties": false,
        "properties": {
          "doc_type": {
            "type": "string",
            "description": "Logical document type, e.g. `PAYSLIP`, `RECEIPT`, `OFFER_LETTER`, `ID_PROOF` (db 14 §4)."
          },
          "content_type": {
            "type": "string"
          },
          "file_name": {
            "type": "string"
          },
          "size_bytes": {
            "type": "integer",
            "minimum": 0,
            "description": "Client-declared estimate; the confirmed size is captured by the owning module's registration call."
          }
        }
      },
      "FileUploadUrlResult": {
        "type": "object",
        "required": [
          "storage_key",
          "upload_url",
          "expires_at"
        ],
        "additionalProperties": false,
        "properties": {
          "storage_key": {
            "type": "string",
            "description": "Object key `tenant_id/<entity>/<entity_id>/<doc_type>/<uuid>.<ext>` (db 00 §14) — pass to the owning module's create/update op."
          },
          "upload_url": {
            "type": "string",
            "format": "uri",
            "description": "Time-limited presigned PUT target. Bytes go directly here",
            "never through this API.": null
          },
          "expires_at": {
            "$ref": "#/components/schemas/TimestampRef"
          },
          "headers": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "Request headers the presign SIGNED. When present the client MUST send exactly these on the `PUT` — SigV4 covers them, so an omitted or altered value is a `403 SignatureDoesNotMatch` (issue #1408). Absent when only `host` is signed, in which case the client may send its own `Content-Type`. Never a credential: today this is at most `Content-Type`.\n"
          }
        }
      },
      "File": {
        "description": "xc.object_refs — one row per stored file (db 14 §4). Bytes never live in Postgres.",
        "type": "object",
        "required": [
          "id",
          "owner_type",
          "owner_id",
          "doc_type",
          "storage_key",
          "lifecycle_tier"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "owner_type": {
            "type": "string",
            "description": "Polymorphic owner module entity, e.g. `pay.payslips`, `expense.receipts`."
          },
          "owner_id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "doc_type": {
            "type": "string"
          },
          "content_type": {
            "type": [
              "string",
              "null"
            ]
          },
          "size_bytes": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0
          },
          "content_hash": {
            "type": [
              "string",
              "null"
            ],
            "description": "SHA-256",
            "present where tamper-evidence matters.": null
          },
          "lifecycle_tier": {
            "$ref": "#/components/schemas/FileLifecycleTier"
          },
          "download": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/FileDownloadRef"
              },
              {
                "type": "null"
              }
            ],
            "description": "Freshly minted presigned download handle."
          },
          "created_at": {
            "$ref": "#/components/schemas/TimestampRef"
          }
        }
      },
      "DashboardScope": {
        "type": "string",
        "description": "The role view a home tile is built for. `DIVISION` — the sixth `xc.dashboard_scope` value, added by migration 0164 for ADM-S08 — is deliberately absent: it is not a home-tile persona, and it is not an accepted value on `xc.dashboard_projection.list`.\n",
        "enum": [
          "EMPLOYEE",
          "MANAGER",
          "HR",
          "FINANCE",
          "ADMIN"
        ]
      },
      "WidgetSurface": {
        "type": "string",
        "enum": [
          "MOBILE",
          "WEB"
        ]
      },
      "FinanceKpiRead": {
        "description": "A tenant-wide enterprise KPI projection. `data: null` means the producer has not written a projection for this tenant; it is never a numeric zero. (Rev. 2026-09-02: its screen, PAY-S19, is withdrawn by ADR 0069 (i); the schema and its producers are unchanged and the tokens are retained - SGAP-45.)",
        "type": "object",
        "required": [
          "projection_key",
          "data",
          "as_of"
        ],
        "additionalProperties": false,
        "properties": {
          "projection_key": {
            "type": "string",
            "enum": [
              "finance.headcount_cost",
              "finance.utilization",
              "finance.invoiced_vs_cost"
            ]
          },
          "data": {
            "anyOf": [
              {
                "type": "object"
              },
              {
                "type": "null"
              }
            ],
            "description": "Projection-key-specific KPI payload."
          },
          "as_of": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/TimestampRef"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "DashboardProjection": {
        "description": "xc.dashboard_projections — an event-built read-model tile (db 14 §5).",
        "type": "object",
        "required": [
          "id",
          "scope",
          "projection_key",
          "data",
          "as_of"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "subject_identity_id": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/UuidRef"
              },
              {
                "type": "null"
              }
            ],
            "description": "null for a tenant/role-scoped tile."
          },
          "scope": {
            "$ref": "#/components/schemas/DashboardScope"
          },
          "projection_key": {
            "type": "string",
            "description": "e.g. `pending_approvals`, `leave_balance`, `next_payslip`, `iqama_expiry`."
          },
          "data": {
            "type": "object",
            "description": "Denormalised tile payload — shape varies by `projection_key` (db 14 §5)."
          },
          "source_event_type": {
            "type": [
              "string",
              "null"
            ]
          },
          "as_of": {
            "$ref": "#/components/schemas/TimestampRef"
          }
        }
      },
      "DashboardProjectionPage": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CursorPageRef"
          },
          {
            "type": "object",
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/DashboardProjection"
                }
              }
            }
          }
        ]
      },
      "HomeWidget": {
        "description": "xc.home_widgets — role-aware home layout config (db 14 §5).",
        "type": "object",
        "required": [
          "id",
          "widget_key",
          "scope",
          "surface",
          "position",
          "is_visible"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "widget_key": {
            "type": "string",
            "description": "e.g. `quick_actions`, `pending_approvals`, `announcements`, `balances`."
          },
          "scope": {
            "$ref": "#/components/schemas/DashboardScope"
          },
          "surface": {
            "$ref": "#/components/schemas/WidgetSurface"
          },
          "position": {
            "type": "integer",
            "minimum": 0
          },
          "is_visible": {
            "type": "boolean"
          },
          "config": {
            "type": [
              "object",
              "null"
            ],
            "description": "Widget-specific options — data source key, size, action set (db 14 §5)."
          }
        }
      },
      "HomeWidgetPage": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CursorPageRef"
          },
          {
            "type": "object",
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/HomeWidget"
                }
              }
            }
          }
        ]
      },
      "EmployeeHomeHoliday": {
        "type": "object",
        "description": "One upcoming holiday on the caller's own resolved calendar (#1637). Field-for-field the `EmployeeHoliday` row `GET /holidays/me` returns (04-attendance-leave), restated here rather than cross-referenced because 00 §5 keeps every field ref inside its own file's schema namespace — the two MUST be kept in step by hand, which is what this sentence is for.\n",
        "readOnly": true,
        "additionalProperties": false,
        "required": [
          "date",
          "name",
          "type",
          "is_optional",
          "calendar_id",
          "fiscal_year",
          "is_claimed",
          "claim_application_id"
        ],
        "properties": {
          "date": {
            "$ref": "#/components/schemas/DateOnlyRef"
          },
          "name": {
            "description": "The authored localized name, verbatim (`{ en, ar, … }`); a legacy entry may carry a bare string.",
            "anyOf": [
              {
                "type": "object",
                "additionalProperties": {
                  "type": "string"
                }
              },
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "type": {
            "type": [
              "string",
              "null"
            ],
            "description": "`PUBLIC` · `RELIGIOUS` · `NATIONAL` · `REGIONAL` · `RESTRICTED`, or null."
          },
          "is_optional": {
            "type": "boolean"
          },
          "calendar_id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "fiscal_year": {
            "type": "integer"
          },
          "is_claimed": {
            "type": "boolean",
            "description": "True only for an optional date this employee has claimed (#1636)."
          },
          "claim_application_id": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/UuidRef"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "EmployeeHomeOptionalHolidayQuota": {
        "type": "object",
        "description": "The caller's optional-holiday ledger (#1636) — the same numbers `GET /optional-holidays/me` reports, minus its per-fiscal-year `claimed_count`. Day quantities are exact decimal strings.\n",
        "readOnly": true,
        "additionalProperties": false,
        "required": [
          "leave_type_id",
          "leave_type_code",
          "leave_type_name",
          "quota_total",
          "quota_remaining"
        ],
        "properties": {
          "leave_type_id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "leave_type_code": {
            "type": "string"
          },
          "leave_type_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "quota_total": {
            "type": "string"
          },
          "quota_remaining": {
            "type": "string"
          }
        }
      },
      "EmployeeHome": {
        "description": "The composed XC-S07 Home screen. Not a stored entity — a read model assembled per request from `people`, `attend`, `leave` and `engage`, each with the owning endpoint's own predicate.\n",
        "type": "object",
        "readOnly": true,
        "required": [
          "greeting_name",
          "attendance_timezone",
          "checked_in_now",
          "today",
          "leave_balances",
          "upcoming_holidays",
          "optional_holiday_quota",
          "next_pay_day",
          "last_net_pay",
          "my_pending_requests",
          "approvals_pending_count",
          "approvals_overdue_count",
          "who_is_out_today",
          "attendance_location_policy",
          "attendance_location_unrestricted",
          "allow_mobile_attendance_anywhere",
          "mobile_location_attestation_required",
          "assigned_geofences",
          "announcements"
        ],
        "additionalProperties": false,
        "properties": {
          "greeting_name": {
            "type": "string",
            "description": "ref→people.employees.full_name — the name the greeting line renders."
          },
          "worker_profile": {
            "type": "string",
            "enum": [
              "STANDARD",
              "MUSTER_DAILY"
            ],
            "description": "MUSTER_DAILY when work mode MUSTER and tenant muster_ess_restricted is on."
          },
          "features": {
            "type": "object",
            "required": [
              "payslips",
              "leave",
              "absent_appeal",
              "self_attendance",
              "work_tab"
            ],
            "properties": {
              "payslips": {
                "type": "boolean"
              },
              "leave": {
                "type": "boolean"
              },
              "absent_appeal": {
                "type": "boolean"
              },
              "self_attendance": {
                "type": "boolean"
              },
              "work_tab": {
                "type": "boolean"
              }
            },
            "description": "All true for STANDARD; for MUSTER_DAILY only self_attendance follows the tenant switch."
          },
          "muster_day": {
            "description": "Own worker-day view, or null for a non-MUSTER employee. See attend.muster_day.get_me.",
            "type": [
              "object",
              "null"
            ]
          },
          "attendance_timezone": {
            "type": "string",
            "example": "Asia/Kolkata",
            "description": "The IANA timezone used to settle the caller's attendance work date and render punch instants. Resolution matches `attend.punch.sync`: captured location → assigned location → pay group → tenant config → market default → UTC fallback.\n"
          },
          "checked_in_now": {
            "type": "boolean",
            "description": "Server-resolved current clock-in status shared by mobile and ESS. Use this boolean, not the daily first-IN/last-OUT columns. Refresh after punches and when returning to the app."
          },
          "today": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/EmployeeHomeToday"
              },
              {
                "type": "null"
              }
            ],
            "description": "Null when the caller has neither an attendance record nor a shift assignment for today."
          },
          "leave_balances": {
            "type": "array",
            "description": "Identical rows to `leave.leave_balance.summary` for the caller, ordered by leave-type display name.",
            "items": {
              "$ref": "#/components/schemas/EmployeeHomeLeaveBalance"
            }
          },
          "upcoming_holidays": {
            "type": "array",
            "description": "The next THREE non-working holidays for this employee, ascending from today in their own attendance timezone — what the dashboard's Upcoming Holidays tile renders.\n\n**This block did not exist before [#1637](https://github.com/Sysmeadac/GroundIT/issues/1637)**, which is why the tile was empty on every tenant: the aggregate carried no holidays, and the clients' only other door was the ADMIN `GET /holiday-calendars`, refused to every plain employee. Same resolver as `GET /holidays/me` — the caller's own legal entity and work location, ACTIVE calendars only — so the tile and the holiday screen agree.\n\nAn UNCLAIMED optional (RESTRICTED) date is **not** here: it is a day the employee may take, not one they have been given. A date they have CLAIMED (#1636) is.\n",
            "items": {
              "$ref": "#/components/schemas/EmployeeHomeHoliday"
            }
          },
          "optional_holiday_quota": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/EmployeeHomeOptionalHolidayQuota"
              },
              {
                "type": "null"
              }
            ],
            "description": "The employee's remaining optional-holiday quota (#1636) — their ledger on the tenant's `OPTIONAL_HOLIDAY` leave type.\n\n**`null` means the tenant has authored no such leave type**, and the Home figure hides. It is never a zero: a zero says \"you have used them all\", which is a different fact and the one the employee would act on differently. `claimed_count` is omitted here — the per-date detail belongs to `GET /optional-holidays/me`.\n"
          },
          "next_pay_day": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/DateOnlyRef"
              },
              {
                "type": "null"
              }
            ],
            "description": "ref→pay.payroll_runs.pay_date — the caller's legal entity's earliest non-cancelled run paying on or after today. **Null when no such run exists, and never derived**: Home omits the payday line rather than projecting one. Source moves to the pay period's own pay day when `pay.pay_periods` lands (GAP-ESS-01); the field keeps its name and shape.\n"
          },
          "last_net_pay": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/EmployeeHomeLastNetPay"
              },
              {
                "type": "null"
              }
            ],
            "description": "Null for an employee with no PUBLISHED payslip yet — the designed first-run state, not an error and not a zero."
          },
          "my_pending_requests": {
            "type": "array",
            "maxItems": 4,
            "description": "The caller's in-flight requests from the REQUESTER side of `xc.approval_inbox`, newest first with `RETURNED` rows sorted to the top. Empty for an employee with nothing open.\n",
            "items": {
              "$ref": "#/components/schemas/EmployeeHomePendingRequest"
            }
          },
          "approvals_pending_count": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "description": "Approvals routed to the caller (`current_approver_id` or `effective_approver_id`) still `PENDING` — the same predicate `xc.approval_inbox.list` uses. **Null, not 0, when the caller does not hold `xc.approval_inbox.list`**: \"not an approver\" and \"no approvals waiting\" are different facts and Home renders only the second.\n"
          },
          "approvals_overdue_count": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "description": "Of the above, those with `sla_due_at < now()`. Null exactly when `approvals_pending_count` is null — the two move together."
          },
          "who_is_out_today": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/EmployeeHomeWhoIsOut"
              },
              {
                "type": "null"
              }
            ],
            "description": "The §4.7 team-availability card (`GAP-ESS-02`). **Null means the caller has no team at all** — no manager, no peers, no direct reports — and the client renders the card NOT AT ALL. A present block with two empty arrays means the caller HAS a team and every one of them is in, which the card states as a fact. The two are different answers and are never collapsed.\n"
          },
          "attendance_location_policy": {
            "type": "string",
            "enum": [
              "LEGACY",
              "ALLOWED_LOCATIONS"
            ]
          },
          "attendance_location_unrestricted": {
            "type": "boolean",
            "description": "True only when an active unrestricted Remote location is explicitly assigned. For LEGACY policy, use `assigned_geofences[]` from this response; an empty self-scoped ALLOWED_LOCATIONS list is not an unrestricted signal."
          },
          "allow_mobile_attendance_anywhere": {
            "type": "boolean",
            "readOnly": true,
            "description": "HR/Admin-controlled exemption. When true, mobile location permission, GPS, and geofence capture are skipped; face verification still applies."
          },
          "mobile_location_attestation_required": {
            "type": "boolean",
            "readOnly": true,
            "description": "Effective mobile location requirement after the employee toggle, placement exemptions, and allowed-location policy are resolved."
          },
          "assigned_geofences": {
            "type": "array",
            "description": "GAP-35 (#963). The fence(s) assigned to the CALLER — never the tenant's whole `org.geofences` list, and reachable with no `org.geofence.*` token: the aggregate's own `xc.employee_home.get` token is the whole authority, because this is the caller reading their own assignment. Always an array, never null — see `EmployeeHomeGeofence` for what an empty array does and does not mean.\n",
            "items": {
              "$ref": "#/components/schemas/EmployeeHomeGeofence"
            }
          },
          "announcements": {
            "type": "array",
            "maxItems": 5,
            "description": "The first 5 rows of `engage.announcement.list` — PUBLISHED, unexpired, audience-targeted, pinned first then newest.",
            "items": {
              "$ref": "#/components/schemas/EmployeeHomeAnnouncement"
            }
          }
        }
      },
      "EmployeeHomeGeofence": {
        "description": "GAP-35 (#963) — one `org.geofences` row the caller is assigned to, embedded so the PWA can cache it and evaluate punch containment ON-DEVICE (ADR 0004) without a second round trip and without the org-admin-scoped `org.geofence.list` token. Resolution, most specific first: today's shift assignment's own `geofence_id` when the roster build named one directly; otherwise every active fence at that assignment's `work_location_id`, or, with no located assignment today, at the caller's own `people.employees.work_location_id`. Never the tenant-wide nearest-fence fallback `attend.punch.sync` uses to salvage a punch — Home has no punch to salvage, so an employee with neither signal gets an honest empty set.\n\nBoth shapes share ONE flat object (never a `oneOf`) so a client checks `shape` once and reads straight through: `center`/`radius_m` are populated for `CIRCLE` and null for `POLYGON`; `polygon` is populated for `POLYGON` and null for `CIRCLE`.\n\n**A claim, never a verdict (ADR 0004/0025).** This is geometry for the handset's own pre-check, not an attendance verdict; the punch write path re-derives containment server-side from the punch's own coordinates and overrides whatever the client claims.\n",
        "type": "object",
        "readOnly": true,
        "required": [
          "id",
          "shape",
          "center",
          "radius_m",
          "polygon"
        ],
        "additionalProperties": false,
        "properties": {
          "employee_attendance_location_id": {
            "type": "string",
            "format": "uuid",
            "description": "Present for an owned personal boundary; its id is not an org geofence ID and must not be sent as geofence_id."
          },
          "id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "shape": {
            "type": "string",
            "enum": [
              "CIRCLE",
              "POLYGON"
            ]
          },
          "center": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/EmployeeHomeGeoPoint"
              },
              {
                "type": "null"
              }
            ],
            "description": "ref→org.geofences.center_lat/center_lng."
          },
          "radius_m": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "description": "ref→org.geofences.radius_m, metres."
          },
          "polygon": {
            "anyOf": [
              {
                "type": "array",
                "minItems": 3,
                "items": {
                  "$ref": "#/components/schemas/EmployeeHomeGeoPoint"
                }
              },
              {
                "type": "null"
              }
            ],
            "description": "ref→org.geofences.polygon — the ordered ring, ≥3 vertices."
          }
        }
      },
      "EmployeeHomeGeoPoint": {
        "type": "object",
        "readOnly": true,
        "required": [
          "lat",
          "lng"
        ],
        "additionalProperties": false,
        "properties": {
          "lat": {
            "type": "number",
            "minimum": -90,
            "maximum": 90
          },
          "lng": {
            "type": "number",
            "minimum": -180,
            "maximum": 180
          }
        }
      },
      "EmployeeHomeWhoIsOut": {
        "description": "Who on the caller's team is out today, and whose day it is (design-ess/02 §4.7, `GAP-ESS-02`). Team reach is the owner's 2026-08-13 decision: the caller's peers (everyone reporting to the caller's manager), the caller's manager, and the caller's own direct reports — derived from `people.org_directory`, capped at 200, and never widened to the department or the tenant. The caller is excluded from both arrays: their own leave and their own birthday are elsewhere on this Home.\n",
        "type": "object",
        "readOnly": true,
        "required": [
          "out_today",
          "moments"
        ],
        "additionalProperties": false,
        "properties": {
          "out_today": {
            "type": "array",
            "description": "Teammates with an APPROVED `leave.leave_applications` row covering today, by display name. **This is the whole disclosure**: that a named colleague is out today and on what type of leave — the same thing an out-of-office reply carries. The reason, the application number and the leave's own start/end dates are never projected.\n",
            "items": {
              "$ref": "#/components/schemas/EmployeeHomeOutToday"
            }
          },
          "moments": {
            "type": "array",
            "description": "Birthdays and work anniversaries falling today, plus joiners of the last seven days, ordered `BIRTHDAY` → `WORK_ANNIVERSARY` → `NEW_JOINER` then by name. **Gated on each subject's own `contact_visibility`** (`GAP-XRS-2`): `PUBLIC`/`COLLEAGUES` render to the team, `MANAGER_HR` renders only for that subject's manager, `PRIVATE` renders to nobody. A teammate who has opted out is absent from this array and can still be present in `out_today` — see the operation description for why the two differ.\n",
            "items": {
              "$ref": "#/components/schemas/EmployeeHomeMoment"
            }
          }
        }
      },
      "EmployeeHomeOutToday": {
        "type": "object",
        "readOnly": true,
        "required": [
          "employee_id",
          "display_name",
          "leave_type_name"
        ],
        "additionalProperties": false,
        "properties": {
          "employee_id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "display_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "ref→people.org_directory.display_name — the tenant-readable projection, not `people.employees.full_name`."
          },
          "leave_type_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "ref→org.leave_types.name (locale-keyed, `en` then `ar`). Null when the type was retired — still a real absence, so the row is rendered, not dropped."
          }
        }
      },
      "EmployeeHomeMoment": {
        "description": "One moment on the caller's team. **No date of birth is ever carried here.** The day-and-month match is made server-side against `people.employee_profiles.birthday` and the birth year is never read; a `BIRTHDAY` or `WORK_ANNIVERSARY` row's `date` is TODAY. A 29-February birthday does not match in a common year — the card declines to invent a date for someone.\n",
        "type": "object",
        "readOnly": true,
        "required": [
          "employee_id",
          "display_name",
          "kind",
          "years",
          "date"
        ],
        "additionalProperties": false,
        "properties": {
          "employee_id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "display_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "ref→people.org_directory.display_name."
          },
          "kind": {
            "type": "string",
            "enum": [
              "BIRTHDAY",
              "WORK_ANNIVERSARY",
              "NEW_JOINER"
            ]
          },
          "years": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "description": "Completed years of service, on `WORK_ANNIVERSARY` only — the card's `3 years today`. Null on the other two kinds: a birthday's age is nobody's business and a new joiner has no years to count."
          },
          "date": {
            "$ref": "#/components/schemas/DateOnlyRef",
            "description": "TODAY for `BIRTHDAY`/`WORK_ANNIVERSARY`; ref→people.employees.date_of_joining for `NEW_JOINER`, which is what makes `Joined Monday` renderable."
          }
        }
      },
      "EmployeeHomeLastNetPay": {
        "description": "The caller's own latest PUBLISHED payslip, reduced to the one figure Home renders (masked by default — design-ess/02 §4.3). The row links to `PAY-S26` via `payslip_id`.\n",
        "type": "object",
        "readOnly": true,
        "required": [
          "amount",
          "currency_code",
          "payslip_id"
        ],
        "additionalProperties": false,
        "properties": {
          "amount": {
            "type": "string",
            "pattern": "^-?\\d+(\\.\\d{1,2})?$",
            "description": "ref→pay.payslips.net_pay_amount — the DECIMAL STRING as the driver returned it. Never a JSON number: the money doctrine keeps this value out of float space end to end."
          },
          "currency_code": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "ref→pay.payslips.currency_code."
          },
          "payslip_id": {
            "$ref": "#/components/schemas/UuidRef"
          }
        }
      },
      "EmployeeHomePendingRequest": {
        "description": "One in-flight request of the caller's, projected from `xc.approval_inbox` (db 14 §6). Home owns neither the request nor its truth — `deep_link_ref` names the owning module's record, where the request is edited or withdrawn **in place** (never cancel-and-resubmit).\n",
        "type": "object",
        "readOnly": true,
        "required": [
          "id",
          "request_type",
          "status",
          "submitted_at",
          "sla_due_at",
          "step_no",
          "priority",
          "current_approver_display_name",
          "amount",
          "currency_code",
          "deep_link_ref"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "request_type": {
            "$ref": "#/components/schemas/ApprovalRequestType"
          },
          "status": {
            "type": "string",
            "enum": [
              "IN_REVIEW",
              "RETURNED",
              "APPROVED",
              "REJECTED",
              "WITHDRAWN"
            ],
            "description": "`xc.approval_status` mapped at this edge into the ESS request-lifecycle vocabulary (design-ess/00 §6), so no client invents a synonym chip: `PENDING`/`ESCALATED` → `IN_REVIEW` (an escalation moved the ball to another approver, not back to the employee), `EXPIRED` → `RETURNED` (actionable by the requester again). Only non-terminal states are returned today, so `APPROVED`/`REJECTED`/`WITHDRAWN` are reserved, not emitted.\n"
          },
          "submitted_at": {
            "$ref": "#/components/schemas/TimestampRef"
          },
          "sla_due_at": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/TimestampRef"
              },
              {
                "type": "null"
              }
            ],
            "description": "When the current step breaches. Null when the routing rule set no timeout."
          },
          "step_no": {
            "type": "integer",
            "minimum": 1
          },
          "priority": {
            "$ref": "#/components/schemas/ApprovalPriority"
          },
          "current_approver_display_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "ref→people.org_directory.display_name for `effective_approver_id` (a delegatee, XC-F14) else `current_approver_id` — the whose-court chip's name. **Null is a stated fact**: an unrouted row renders \"awaiting assignment\", never a uuid and never a silent omission.\n"
          },
          "amount": {
            "anyOf": [
              {
                "type": "string",
                "pattern": "^-?\\d+(\\.\\d{1,2})?$"
              },
              {
                "type": "null"
              }
            ],
            "description": "ref→xc.approval_inbox.amount — the request's money value where it has one (a claim, an advance). Decimal string, as above."
          },
          "currency_code": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 3,
            "maxLength": 3
          },
          "deep_link_ref": {
            "type": "object",
            "description": "The ids the client needs to route to the owning module's own record.",
            "required": [
              "source_type",
              "source_id",
              "request_type"
            ],
            "additionalProperties": false,
            "properties": {
              "source_type": {
                "type": "string",
                "description": "e.g. `leave.leave_applications`, `attend.overtime_requests`."
              },
              "source_id": {
                "$ref": "#/components/schemas/UuidRef"
              },
              "request_type": {
                "$ref": "#/components/schemas/ApprovalRequestType"
              }
            }
          }
        }
      },
      "EmployeeHomeToday": {
        "description": "`attend.attendance_records` (UNIQUE per tenant/employee/work_date) with the shift behind it.\n\n**Falls back to the caller's most recent record (on or before today) when today has none**, so the block shows the last known check-in instead of going `null` — which read as \"you have no attendance\" rather than \"you have not checked in yet today\". `work_date` is therefore the RECORD's own day and is NOT guaranteed to be today: clients must render it, never assume it. `checked_in_now` on the parent object stays strictly today-scoped, so a still-open record from an earlier day never reads as \"currently checked in\" — and since #1625 it is derived from whether a punch SESSION is open, not from `clock_in_at != null && clock_out_at == null`. The day row's two columns are now the day's FIRST IN and LAST OUT of a day that may hold several stretches, so they cannot answer \"is this person working right now\" at all.\n",
        "type": "object",
        "readOnly": true,
        "required": [
          "work_date",
          "clock_in_at",
          "clock_out_at",
          "worked_hours",
          "worked_minutes_live",
          "as_of",
          "sessions",
          "shift",
          "allowance"
        ],
        "additionalProperties": false,
        "properties": {
          "work_date": {
            "$ref": "#/components/schemas/DateOnlyRef"
          },
          "clock_in_at": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/TimestampRef"
              },
              {
                "type": "null"
              }
            ],
            "description": "The day's FIRST IN. Since #1625 a day may hold several stretches; this is not \"the\" clock-in."
          },
          "clock_out_at": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/TimestampRef"
              },
              {
                "type": "null"
              }
            ],
            "description": "The day's LAST OUT. Set does NOT mean the day is over — the employee may clock in again."
          },
          "worked_hours": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/DecimalHoursRef"
              },
              {
                "type": "null"
              }
            ],
            "description": "The CLOSED-day figure, straight off `attend.attendance_records.worked_hours`, which the attendance cycle and payroll read. Since #1625 it is Σ CLOSED sessions minus the shift's `break_minutes` once (#1630), never the gross span. `null` while the day has no closed session — \"not finished\" is not \"worked nothing\", and a client showing a running figure wants `worked_minutes_live` below.\n"
          },
          "worked_minutes_live": {
            "type": [
              "integer",
              "null"
            ],
            "description": "**#1630** — worked minutes SO FAR, computed server-side: Σ the day's closed sessions plus `as_of − in_at` of the open one, minus the shift's `break_minutes` once (and only once the raw total passes it). `null` when the day has no sessions at all — never `0`, because \"has not started\" and \"has worked nothing\" are different facts and a zero would make a client render a running `00:00` for somebody who has not punched.\n\nBefore this the block returned only `worked_hours`, which is written when the day CLOSES — so from clock-in until clock-out the field was `null`, the app had nothing to render, and the PWA papered over it with a client-side stopwatch that disagreed with the server all day. **A client must EXTRAPOLATE from this figure and `as_of`, never run an independent timer:** only the server knows about the break and about the other stretches of the day.\n"
          },
          "as_of": {
            "$ref": "#/components/schemas/TimestampRef",
            "description": "The instant `worked_minutes_live` and every open session's `worked_minutes` were measured at — the true `now()`. `sessions[]` is the employee's own account of their day and is returned RAW; **#1632** does not rewrite it. The live FIGURE is what the allotment governs: `worked_minutes_live` is held at the day's cap, so it can never exceed the allotment, and a client extrapolating forward from this stamp must therefore ALSO stop at `allowance.allowance_minutes_remaining` — see that field.\n"
          },
          "sessions": {
            "type": "array",
            "description": "**#1625** — the day's clock-in/clock-out stretches, OLDEST FIRST. `out_at: null` is the open one (at most one per employee-day). This is the punch-level time log the mobile team asked for, carried on the aggregate the home screen already reads, so the dashboard needs no second round trip. The same rows are available on their own at `GET /attendance-records/{id}/sessions` (module 04).\n",
            "items": {
              "$ref": "#/components/schemas/EmployeeHomePunchSession"
            }
          },
          "shift": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/EmployeeHomeShift"
              },
              {
                "type": "null"
              }
            ],
            "description": "Null when today has an attendance record but no shift assignment behind it."
          },
          "allowance": {
            "$ref": "#/components/schemas/EmployeeHomeAllowance"
          }
        }
      },
      "EmployeeHomeAllowance": {
        "description": "**#1632** — THE DAY'S ALLOTMENT and its overtime posture. Field-for-field the `allowance` block of `GET /attendance-records/{id}/sessions` (module 04's `AllowanceBlock`), carried here so the home hero needs no second round trip.\n\nThe allotment is `org.shifts.working_hours` — the hours OWED — and NOT the shift's `start_time`/`end_time` window: eight hours may be worked 10:00–18:00 or 12:00–20:00 on a shift nominally 09:00–17:00. Past it a further clock-IN is refused (`OT_NOT_APPROVED`) until the reporting manager approves an overtime request, which extends this allowance by its hours and re-opens punching for the date.\n\n**RECORDED vs COUNTED.** A clock-OUT past the allotment is never refused and never rewritten: the stretch and `clock_out_at` hold the true instant the employee left. The allotment governs only what is COUNTED (`worked_hours`, `worked_minutes_live`), which is what lets an after-the-fact overtime claim be measured against recorded excess — and lets an approval unlock that excess without a regularisation.\n\n**A client renders \"X of 8h worked, Yh remaining\" and the overtime badge from these figures and formats them. It computes nothing** — which is the same discipline `worked_minutes_live` established, applied to the allotment.\n",
        "type": "object",
        "readOnly": true,
        "required": [
          "working_hours",
          "break_minutes",
          "allowance_minutes",
          "allowance_minutes_remaining",
          "overtime"
        ],
        "additionalProperties": false,
        "properties": {
          "working_hours": {
            "type": [
              "string",
              "null"
            ],
            "description": "The shift's own `numeric(9,2)` decimal-hours string; `null` when the day is UNCAPPED."
          },
          "break_minutes": {
            "type": "integer",
            "description": "The once-per-day deduction ALREADY applied to `worked_minutes_live` — never to be subtracted again."
          },
          "allowance_minutes": {
            "type": [
              "integer",
              "null"
            ],
            "description": "NET minutes the day allows: `working_hours` plus any APPROVED overtime for the date. A PENDING request adds nothing. `null` = UNCAPPED (no shift assignment for the day, or its shift retired), and a client must then render no \"of 8h\" at all rather than inventing one.\n"
          },
          "allowance_minutes_remaining": {
            "type": [
              "integer",
              "null"
            ],
            "description": "What is left of the allotment, measured against `worked_minutes_live`, floored at zero. `0` means the allotment is SPENT — the clock has stopped and the next clock-in is refused — which is a different fact from `null` (uncapped). It is also the CEILING a client extrapolating from `as_of` must stop at.\n"
          },
          "overtime": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "state",
              "requested_hours",
              "approved_hours",
              "request_id"
            ],
            "description": "The day's overtime posture, in the owner's three words.",
            "properties": {
              "state": {
                "type": "string",
                "enum": [
                  "NONE",
                  "REQUESTED",
                  "APPROVED"
                ]
              },
              "requested_hours": {
                "type": "string",
                "description": "Σ still-PENDING `ot_hours`, decimal string."
              },
              "approved_hours": {
                "type": "string",
                "description": "Σ APPROVED/PROCESSED hours — the extension the allowance gained."
              },
              "request_id": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/UuidRef"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "The newest live request on the day, for a deep link."
              }
            }
          }
        }
      },
      "EmployeeHomePunchSession": {
        "description": "One clock-in/clock-out stretch of the day (`attend.punch_sessions`, migration 0227, #1625). Field-for-field the `PunchSession` of `attend.attendance_record.get_sessions` (04-attendance-leave.openapi.yaml) — the same rows from the same statement, so the home hero and the attendance screen cannot disagree about how a day was worked.\n",
        "type": "object",
        "readOnly": true,
        "required": [
          "id",
          "attendance_record_id",
          "work_date",
          "in_at",
          "out_at",
          "worked_minutes"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "attendance_record_id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "work_date": {
            "$ref": "#/components/schemas/DateOnlyRef"
          },
          "in_at": {
            "$ref": "#/components/schemas/TimestampRef"
          },
          "out_at": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/TimestampRef"
              },
              {
                "type": "null"
              }
            ],
            "description": "NULL = the stretch is still open — the employee is working right now."
          },
          "source": {
            "type": [
              "string",
              "null"
            ],
            "description": "ref→attend.attendance_source — how the opening punch was captured."
          },
          "out_source": {
            "type": [
              "string",
              "null"
            ],
            "description": "ref→attend.attendance_source — how the closing punch was captured (migration 0267). `null` while open, or for a legacy/automatic close whose OUT channel was never recorded.\n"
          },
          "face_matched": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "The SERVER's own face verdict for the opening punch (#1530). `null` = could not check, deliberately distinct from `false`."
          },
          "geofence_ok": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "`true` INSIDE a fence, `false` OUTSIDE, `null` the server could not tell. Never the handset's claim (ADR 0025)."
          },
          "work_location_id": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/UuidRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "worked_minutes": {
            "type": "integer",
            "description": "Whole minutes this stretch has run for, measured against `as_of`. The day's break is NOT deducted here — it is a once-per-day deduction on the totals."
          }
        }
      },
      "EmployeeHomeShift": {
        "description": "org.shifts, reached through today's attend.shift_assignments row — the hero's shift window.",
        "type": "object",
        "readOnly": true,
        "required": [
          "name",
          "start_time",
          "end_time",
          "grace_in_minutes",
          "working_hours",
          "break_minutes"
        ],
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "ref→org.shifts.name, by projection (not a join) — English preferred, Arabic-only fallback."
          },
          "start_time": {
            "type": "string",
            "pattern": "^([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d)?$",
            "description": "local clock time, rendered in the location tz."
          },
          "end_time": {
            "type": "string",
            "pattern": "^([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d)?$",
            "description": "may be < start_time for overnight shifts."
          },
          "grace_in_minutes": {
            "type": "integer",
            "minimum": 0,
            "description": "Late-mark grace the hero warns against."
          },
          "working_hours": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/DecimalHoursRef"
              },
              {
                "type": "null"
              }
            ],
            "description": "**#1630** — `org.shifts.working_hours`, the shift's ALLOTTED hours (`span − break_minutes`, enforced by `shifts_hours_match_span`). What lets a client render \"7h 00m of 8h 00m\" instead of a bare number, and the figure #1632's OT rule measures the worked total against. `null` when the assignment's shift has been retired.\n"
          },
          "break_minutes": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "description": "**#1630** — `org.shifts.break_minutes`, the break already deducted ONCE from `worked_hours` and `worked_minutes_live`. Exposed so a client can EXPLAIN the gap between the elapsed span and the worked figure rather than looking as though it lost an hour. `null` when the assignment's shift has been retired.\n"
          }
        }
      },
      "EmployeeHomeLeaveBalance": {
        "description": "One leave-balance card. Field-for-field the `LeaveBalanceSummaryItem` of `leave.leave_balance.summary` (04-attendance-leave.openapi.yaml) — aggregated from the append-only `leave.leave_balances` ledger, never a stored balance. `xc.employee_home.get` runs that operation's EXACT statement, so the dashboard and the leave screen cannot disagree about a number; every field below is that schema's, with its meaning. DISPLAY (#1627, #1628): render `available`, not `remaining_days`; `has_ledger: false` means \"no balance posted yet\" and must never render as `0`; an `unlimited` type shows `used` and no remaining or entitlement figure at all. No surface may render a negative remaining.\n",
        "type": "object",
        "readOnly": true,
        "required": [
          "leave_type_id",
          "fiscal_year",
          "accrued_days",
          "used_days"
        ],
        "additionalProperties": false,
        "properties": {
          "leave_type_id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "leave_type_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "ref→org.leave_types.name, by projection (not a join) — English preferred, Arabic-only fallback."
          },
          "leave_type_code": {
            "type": [
              "string",
              "null"
            ],
            "description": "ref→org.leave_types.type_code, by projection — the stable key a client should match on."
          },
          "unit": {
            "type": [
              "string",
              "null"
            ],
            "description": "ref→org.leave_types.unit — DAY or HOUR."
          },
          "is_paid": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Prefer `unlimited`, which states the consequence a client acts on."
          },
          "monthly_limit_days": {
            "type": [
              "string",
              "null"
            ],
            "description": "Applicable calendar-month ceiling, or null for ordinary accrued leave."
          },
          "fiscal_year": {
            "type": "integer"
          },
          "accrued_days": {
            "$ref": "#/components/schemas/DecimalHoursRef"
          },
          "used_days": {
            "$ref": "#/components/schemas/DecimalHoursRef"
          },
          "remaining_days": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/DecimalHoursRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "as_of": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/TimestampRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "entitled": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/DecimalHoursRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "used": {
            "$ref": "#/components/schemas/DecimalHoursRef"
          },
          "pending": {
            "$ref": "#/components/schemas/DecimalHoursRef"
          },
          "available": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/DecimalHoursRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "has_ledger": {
            "type": "boolean"
          },
          "unlimited": {
            "type": "boolean"
          }
        }
      },
      "EmployeeHomeAnnouncement": {
        "description": "One carousel card — the `Announcement` projection of `engage.announcement.list` (10-engage-exit.openapi.yaml) reduced to what the card renders. Open the full notice with `engage.announcement.get`, receipt it with `engage.announcement_read.create`.\n",
        "type": "object",
        "readOnly": true,
        "required": [
          "id",
          "title",
          "category",
          "priority",
          "is_pinned",
          "requires_acknowledgement",
          "published_at",
          "my_read_status"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "title": {
            "$ref": "#/components/schemas/LocalizedTextRef"
          },
          "category": {
            "type": "string",
            "enum": [
              "GENERAL",
              "POLICY",
              "EVENT",
              "HOLIDAY",
              "CELEBRATION",
              "URGENT"
            ],
            "description": "Mirrors `AnnouncementCategory` in 10-engage-exit.openapi.yaml."
          },
          "priority": {
            "type": "string",
            "enum": [
              "NORMAL",
              "IMPORTANT",
              "CRITICAL"
            ],
            "description": "Mirrors `AnnouncementPriority` in 10-engage-exit.openapi.yaml."
          },
          "is_pinned": {
            "type": "boolean"
          },
          "requires_acknowledgement": {
            "type": "boolean"
          },
          "published_at": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/TimestampRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "my_read_status": {
            "type": "object",
            "description": "The caller's own receipt state — drives the unread dot and the Acknowledge CTA.",
            "additionalProperties": false,
            "properties": {
              "read_at": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/TimestampRef"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "acknowledged_at": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/TimestampRef"
                  },
                  {
                    "type": "null"
                  }
                ]
              }
            }
          }
        }
      },
      "ApprovalInboxStatus": {
        "type": "string",
        "enum": [
          "PENDING",
          "APPROVED",
          "REJECTED",
          "WITHDRAWN",
          "ESCALATED",
          "EXPIRED"
        ]
      },
      "ApprovalRequestType": {
        "type": "string",
        "enum": [
          "LEAVE",
          "EXPENSE",
          "TIMESHEET",
          "OVERTIME",
          "OFFER",
          "REGULARIZATION",
          "FNF",
          "PROMOTION",
          "LOAN_ADVANCE",
          "INVOICE",
          "MUSTER",
          "FACE_ENROLMENT",
          "TRAINING_NOMINATION",
          "RESIGNATION",
          "REQUISITION",
          "BANK_CHANGE",
          "IDENTITY_CHANGE",
          "ESOP_EXERCISE",
          "FNF_OVERRIDE",
          "PAYROLL_MONTH",
          "OTHER",
          "ABSENT_APPEAL"
        ],
        "description": "`xc.approval_inbox.request_type` (db 14 §6) — the **same closed vocabulary** shared by `xc.approval_rules.request_type` (`ADM-S09`). **`INVOICE`** is append-only enum growth added by the BOS round for `billing` invoice send/void routing (ADR 0026 §(d), ADR 0027 §(e)). `MUSTER` is append-only growth for the field-workforce approval path. **`FACE_ENROLMENT`** (migration `0150`, D12/GAP-39, `#967`) is the face-enrolment HR approval gate — `attend.face_enrollment.approve`/ `.reject` project into this rail the same way `attend.out_of_zone_event.approve` does. The decision is HR-wide (`x-rls-scope: tenant`) rather than manager-routed, and until the owner ruling of 2026-09-01 (`#1153`) that was taken to mean an inbox row of this type carried no `current_approver_id` and no published `xc.approval_rules` chain. **It IS seeded now** (`ROLE:hr_admin`, escalating to the same role — a seed change, no migration), because an unrouted row is listed to nobody (`GET /approval-inbox` filters on approver = caller) and gets no `sla_due_at`, so the envelope reached no queue and no sweep. The DECISION AUTHORITY IS UNCHANGED: `.approve`/`.reject` remain the only operations that decide one, on their own HR-wide tokens with four-eyes, and the chain merely names which `hr_admin` the row is shown and escalated to. No value is ever renamed or removed. **`TRAINING_NOMINATION`** (migration `0152`) is likewise available to ADM-S09's approval-rule operations, and it IS seeded since `#1646` (`DIRECT_MANAGER`, escalating to `ROLE:hr_admin`; seed only, no migration). It was the last projectable type with no rule at all, on an exemption whose two clauses had both expired — `#1325` added the inbox decision arm it said did not exist, and the learn module's \"own TEAM-scoped queue\" is a permission with no web route behind it. The chain resolves against the envelope's `requested_by`, which for a nomination is the NOMINATOR, so it names the nominator's own manager; `learn.nomination.approve`/`.reject` stay the decision tokens and still refuse the maker. **`RESIGNATION`** (migration `0195`, `#1331`) is the EXT-F01 separation decision, and it IS seeded (`DIRECT_MANAGER`, escalating to `ROLE:hr_admin`): `exit.resignations.approval_ref` had pointed at `xc.approval_inbox` since migration `0149` with nothing populating it, because the manager/HR accept-or-reject had no surface anywhere. The inbox is that surface and the ONLY one — `exit` deliberately mints no duplicate module-level approve/reject operation, the `recruit.requisitions`/`recruit.offers` posture (ADR 0036). `exit.no_dues` stays off this rail: its five departments sign off in parallel and this envelope is a chain (ADR 0027, `#1331`). **`REQUISITION`** (migration `0210`, `#1489`) is the headcount request, which rode `OTHER` until the owner decision of 2026-08-30. It is seeded (`ROLE:hr_admin`, escalating to `ROLE:tenant_admin`) and, with `OFFER`, is one of the two types whose DECISION is role-eligible rather than pinned to the routed approver: any holder of `hr_admin`, `recruiter`, `tenant_admin` or `owner` who is not the maker may decide one, plus any `ROLE:` key the tenant's own published rule names. Routing is unchanged — the chain still names ONE approver, who gets the inbox row, the notification and the SLA clock. `OTHER` keeps its remaining producer, `attend.out_of_zone_events`. **`BANK_CHANGE`/`IDENTITY_CHANGE`** (migration `0232`, `#1643`) are the people module's two sensitive-change decisions, and they are named here for the reason `REQUISITION` was: the catch-all carries no seeded chain, and these route to ROLES rather than to the requester's line manager. Until #1643 `people` was the ONLY approvable module in the tree with no inbox producer of any kind — a bank account or a statutory identifier changed, the row sat `PENDING`, and no envelope, approver, SLA or notification existed, so the HR queue meant to show it was a filter over rows nobody wrote. BOTH ARE SEEDED: `BANK_CHANGE` is `ROLE:hr_admin` then `ROLE:finance` (the two roles that hold `people.bank_account.verify`), `IDENTITY_CHANGE` is `ROLE:hr_admin` alone (`people.identity_document.verify` is HR's only), and both escalate to `hr_admin`. TWO TYPES AND NOT ONE because they route differently and are decided by different tokens; folding them together would pick one chain and be wrong about the other half. The decision authority is unchanged by any of this: the module's own TENANT-scoped verify operations remain the only ones that decide either, under MC-2 (bank, the payroll-redirection vector) and MC-1 (identity) four-eyes. **`ESOP_EXERCISE`/`FNF_OVERRIDE`** (migration `0236`, `#1649`) are the two pay money decisions the enum did not already carry. The other three pay producers reuse members that were already here: `LOAN_ADVANCE` — a member since migration `0020` with **no seeded rule, no written exemption and no producer at all**, the only member ever left in that state — now serves BOTH `pay.salary_advances` and `pay.employee_loans`, because an advance and a loan are the same decision on the same band and one chain suits both; and `FNF`, whose seeded chain had been live and tenant-editable in every tenant with nothing producing it, now has `full-final-settlements/admin/{id}/submit-for-approval` behind it. ALL THREE NEW CHAINS ARE SEEDED `ROLE:finance`, ESCALATING TO `ROLE:hr_admin`: `finance` is the only role holding `pay.salary_advance.approve`/`.reject`, `pay.employee_loan.approve`/`.reject`, `pay.esop_exercise.approve`/`.reject` and `pay.full_final_settlement.override_approve`, so a manager-routed chain would name somebody the decision itself would refuse. `FNF_OVERRIDE` is separate from `FNF` because `#335` split those grants on purpose — `.approve` signs off the CALCULATED statement, `.override_approve` a governed manual DEPARTURE from it — and one shared type would give a tenant one editable chain for two decisions and collapse the rarer queue into the commoner one. The decision authority is unchanged by any of this: the pay module's own TENANT-scoped operations remain the only ones that decide any of the five, under the MC-1/MC-2 four-eyes (and, on the two MC-2 legs, step-up) they already enforced. Until `#1649` **none of the five touched this rail at all**: each was decided by a `POST` on its own admin controller gated by a permission and nothing else, so the largest single-transaction money decisions in the product were the only approvable flows with no approver, no SLA, no escalation, no delegation and no audited override.\n"
      },
      "ApprovalPriority": {
        "type": "string",
        "enum": [
          "LOW",
          "NORMAL",
          "HIGH"
        ]
      },
      "ApprovalSourceType": {
        "type": "string",
        "enum": [
          "leave.leave_applications",
          "attend.overtime_requests",
          "attend.regularizations",
          "expense.expense_claims",
          "attend.out_of_zone_events",
          "attend.muster_rolls",
          "recruit.requisitions",
          "recruit.offers",
          "attend.face_enrollments",
          "engage.promotions",
          "learn.nomination",
          "work.timesheets",
          "exit.resignations",
          "people.bank_accounts",
          "people.personal_info",
          "pay.salary_advances",
          "pay.employee_loans",
          "pay.esop_exercises",
          "pay.full_final_settlements",
          "pay.full_final_settlement_overrides",
          "payroll.months",
          "attend.late_entry_requests"
        ],
        "description": "`xc.approval_inbox.source_type` (db 14 §6) — the OWNING MODULE + TABLE a routed approval came from. Unlike `ApprovalRequestType` this is not a database enum; the column is free text and the closed vocabulary is enforced by the API. This enum is the complete set of producers: every writer of the `xc.approval_inbox.project` event that the generic router (`apps/jobs/src/jobs/xc-approval-router.ts`) turns into an inbox row.\n\n**Use this, not `request_type`, to filter the queue by TYPE.** `request_type` cannot express the distinction a type chip makes: `attend.out_of_zone_events` and `recruit.requisitions` are both routed with `request_type = 'OTHER'`, so a `?request_type=OTHER` query returns both and \"Requisition\" has no `request_type` value a client could send at all. This is the same reason `?facet=type` groups by `source_type` (GAP-37, issue #965) — and it is deliberately the SAME closed set as that facet's categories, one value per category, so any chip a caller can be given a count for is a chip they can then click, and any value they can filter by is one they can be counted for. A value outside this set is a `422`, never a silently empty page.\n\nValues are append-only and never renamed: a new member appears here when a module starts routing a new source into the inbox, together with its `ApprovalInboxFacetCounts` category.\n\n**The five `pay.*` members (`#1649`) are why `source_type` and not `request_type` is the filter axis.** `pay.salary_advances` and `pay.employee_loans` share `request_type = 'LOAN_ADVANCE'` — deliberately, because they are one decision taken by one role on one band — so a `request_type` filter cannot separate an advance queue from a loan queue, while these two values can. `pay.full_final_settlements` and `pay.full_final_settlement_overrides` are the mirror case: two request types (`FNF`, `FNF_OVERRIDE`) over two rows of the same settlement.\n"
      },
      "ApprovalInboxItem": {
        "description": "xc.approval_inbox — one pending/decided approval, polymorphic over its source (db 14 §6). Owns the queue, not the truth.",
        "allOf": [
          {
            "type": "object",
            "required": [
              "id",
              "source_type",
              "source_id",
              "request_type",
              "requested_by",
              "step_no",
              "status",
              "priority",
              "unrouted"
            ],
            "properties": {
              "id": {
                "$ref": "#/components/schemas/UuidRef"
              },
              "source_type": {
                "type": "string",
                "description": "e.g. `leave.leave_applications`, `pay.salary_advances`."
              },
              "source_id": {
                "$ref": "#/components/schemas/UuidRef"
              },
              "request_type": {
                "$ref": "#/components/schemas/ApprovalRequestType"
              },
              "requested_by": {
                "$ref": "#/components/schemas/UuidRef"
              },
              "requested_by_name": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "ref→people.employees.full_name (requested_by), by join on the admin lens and by projection on the SELF lens. Both lenses carry it (issue #1640): the SELF arm runs at `SELF`, where a join would blank every name but the caller's own."
              },
              "org_unit_id": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/UuidRef"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "The requester's SUBMISSION-TIME org placement, snapshotted by the owning source module at routing time and never re-read afterwards — a transfer while a request is pending must not move it between division queues (migration 0164, ADM-S08 / #433). `null` for legacy rows and for any source module that does not emit an anchor; such rows are invisible to an `org_unit`-scoped read by design.\n"
              },
              "source_version": {
                "type": [
                  "integer",
                  "null"
                ],
                "minimum": 0,
                "description": "Owning-record version captured when the envelope was routed; required by versioned source transitions."
              },
              "current_approver_id": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/UuidRef"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "current_approver_name": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "ref→people.employees.full_name (current_approver_id), by join on the admin lens and by projection on the SELF lens (issue #1640)."
              },
              "effective_approver_id": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/UuidRef"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "The delegatee actually acting, when a `xc.delegations` rule applies (XC-F14)."
              },
              "effective_approver_name": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "ref→people.employees.full_name (effective_approver_id), by join on the admin lens and by projection on the SELF lens (issue #1640)."
              },
              "step_no": {
                "type": "integer",
                "minimum": 1
              },
              "status": {
                "$ref": "#/components/schemas/ApprovalInboxStatus"
              },
              "priority": {
                "$ref": "#/components/schemas/ApprovalPriority"
              },
              "sla_due_at": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/TimestampRef"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "decided_by": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/UuidRef"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "decided_at": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/TimestampRef"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "decision_note": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "routing_rule_id": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/UuidRef"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "routing_rule_version": {
                "type": [
                  "integer",
                  "null"
                ],
                "minimum": 1
              },
              "routing_resolver": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The resolver frozen at event time that actually produced current_approver_id. Normally the matched chain step's own resolver; when that step resolved to nobody it is the rule's escalation_target normalized onto the same expression (#1164), or MODULE_FALLBACK:<reason> when the owning module's envelope approver was used instead (#1492). It is the only field recording that a fallback was taken, and — through that reason suffix — the only field recording that the tenant's routing configuration is incomplete on a row that did reach an approver."
              },
              "module_fallback_reason": {
                "type": [
                  "string",
                  "null"
                ],
                "enum": [
                  "NO_PUBLISHED_RULE",
                  "UNRESOLVED_APPROVER",
                  "INVALID_RULE_CHAIN",
                  null
                ],
                "description": "DERIVED, not stored (#1498): the reason suffix of routing_resolver when the owning module routed this row, projected by GET /approval-inbox/admin/module-fallback so an operator surface does not have to parse a resolver string to know which repair a row needs — NO_PUBLISHED_RULE (charter a rule), UNRESOLVED_APPROVER (staff the role), INVALID_RULE_CHAIN (fix the chain). Null on every row that was not module-routed, on the bare MODULE_FALLBACK the writer emits when no reason was pre-empted, and on a suffix this build does not name. Absent from every other approval-inbox read."
              },
              "unrouted": {
                "type": "boolean",
                "description": "True when routing produced no approver at all. A row routed by a fallback is false even when the tenant has no published rule (#1492): that configuration gap is readable off routing_resolver, not off this flag, which is what the SLA sweep skips on. #1497 backfilled the legacy rows written before #1492 fixed the writer — a row that HAD an approver but stayed flagged — so this sentence is now true of existing data as well as of new rows; #1765 also preserves an existing approver when SLA escalation fails, recording escalation_failure separately. Historical SLA rows require the scoped #1765 operator backfill; this migration does not rewrite live tenant data."
              },
              "escalation_failure": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Last unsuccessful SLA escalation attempt (#1765), independent of the original routing result. An existing approver remains assigned and the request stays PENDING with unrouted=false. Null until an escalation fails, and cleared on successful escalation. Failed attempts become eligible again after a five-minute cooldown; sla_due_at retains the original breach deadline. Historical repaired rows may carry a legacy failure marker.\n"
              },
              "routing_failure": {
                "type": [
                  "string",
                  "null"
                ],
                "enum": [
                  "NO_PUBLISHED_RULE",
                  "INVALID_RULE_CHAIN",
                  "UNRESOLVED_APPROVER",
                  "UNSAFE_HISTORICAL_FALLBACK",
                  "MISSING_ROUTING_RULE",
                  "UNRESOLVED_ESCALATION",
                  null
                ]
              },
              "last_refusal_code": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Most recent owning-module refusal code; the row remains PENDING."
              },
              "last_refusal_detail": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "last_refused_at": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/TimestampRef"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "amount": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/Money"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "The request's money value where applicable (maker-checker bands), composed from `amount`/`currency_code`."
              }
            }
          },
          {
            "$ref": "#/components/schemas/AuditMetaRef"
          }
        ]
      },
      "ApprovalInboxPage": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CursorPageRef"
          },
          {
            "type": "object",
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/ApprovalInboxItem"
                }
              }
            }
          }
        ]
      },
      "ApprovalInboxFacetCounts": {
        "description": "`GET /approval-inbox?facet=type` (GAP-37, issue #965) — per-type counts for the mobile approvals inbox's filter chips (design-pwa/03 §9), over the WHOLE `as`/`view`/`status`/ `priority`-filtered set (never one page). Grouped by `source_type`, not `request_type`: `attend.out_of_zone_events` and `recruit.requisitions` are both routed with `request_type = 'OTHER'`, so a `request_type` grouping would fold unrelated requisition approvals into \"Out-of-zone\". `ALL` is the total row count and always equals the sum of every NAMED category below — every `source_type` the approval router (`xc.approval_inbox.project`) can write into this inbox has a chip. That was not always true: a P2 (PWA Supervisor) contract review caught `attend.muster_rolls` counting toward `ALL` with no `MUSTER` chip (musters route in via `transitionMusterSubmit`, undercounting the chips on screen and leaving a supervisor unable to filter to them), and auditing the rest of the router's call sites found the same gap for `recruit.requisitions` and `recruit.offers` — all three were mapped then (`MUSTER`/`REQUISITION`/`OFFER`).\n\n**2026-08-27 (issue #1325).** Re-running that audit for the back-office Unified Approvals page found the remainder had grown back to FOUR unmapped producers — `FACE_ENROLMENT` (`attend.face_enrollments`, GAP-39/#967), `PROMOTION` (`engage.promotions`, ENG-F05/#1160), `TRAINING_NOMINATION` (`learn.nomination`, LRN-F03) and `TIMESHEET` (`work.timesheets`, WRK-F05), each of which started routing into the inbox after the previous audit rather than through one. All four are now mapped, so `ALL` equals the sum of the named categories again. **2026-08-27 (issue #1331)** adds a fifth, `RESIGNATION` (`exit.resignations`, EXT-F01) — mapped in the same change that starts producing it rather than in a later audit. **2026-09-12 (issue #1643)** adds the last two, `BANK_CHANGE` (`people.bank_accounts`) and `IDENTITY_CHANGE` (`people.personal_info`), likewise mapped in the change that starts producing them. Both were absent from this closed set AND from the facet map it backs, so the retired `/people/sensitive-changes` console — which filtered on exactly these two values — would have been answered `422` the moment a row existed, and any row that slipped in would have counted toward `ALL` with no chip to click. **2026-09-13 (issue #1649)** adds the last four — `LOAN_ADVANCE` (`pay.salary_advances` AND `pay.employee_loans`), `ESOP_EXERCISE`, `FNF` and `FNF_OVERRIDE` — the five pay money producers, mapped in the change that starts producing them. `LOAN_ADVANCE` is the one category with TWO source types behind it, and deliberately so: an advance and a loan are the same decision on the same band, so they share a chain and a chip while staying separately filterable through `?source_type=`. Each category corresponds one-to-one with a member of `ApprovalSourceType`, which is also the closed set the `?source_type=` LIST filter validates against — so every count a caller can be shown is a filter they can then apply, and the two sets cannot drift.\n\nClients MAY ignore a category they have no chip for; omitting one is a rendering choice, not an API undercount. The PWA's `/manager/approvals` screen (design-pwa/03 §9) renders `LEAVE` · `OVERTIME` · `REGULARIZATION` · `CLAIM` · `OUT_OF_ZONE` · `GOAL` only, and the rest are back-office-shaped approvals it does not draw. `GOAL` is a declared, truthful zero today: no module routes a manager-facing goal decision into this inbox yet (`perform.goal.accept` is the employee's own SELF-scoped acceptance of a cascaded goal, not an approval) — a distinct, larger gap than this one, and the ONLY category here with no producer behind it.\n",
        "type": "object",
        "readOnly": true,
        "required": [
          "facet",
          "counts"
        ],
        "additionalProperties": false,
        "properties": {
          "facet": {
            "type": "string",
            "enum": [
              "type"
            ]
          },
          "counts": {
            "type": "object",
            "required": [
              "ALL",
              "LEAVE",
              "OVERTIME",
              "REGULARIZATION",
              "CLAIM",
              "OUT_OF_ZONE",
              "GOAL",
              "MUSTER",
              "REQUISITION",
              "OFFER",
              "FACE_ENROLMENT",
              "PROMOTION",
              "TRAINING_NOMINATION",
              "TIMESHEET",
              "RESIGNATION",
              "BANK_CHANGE",
              "IDENTITY_CHANGE",
              "LOAN_ADVANCE",
              "ESOP_EXERCISE",
              "FNF",
              "FNF_OVERRIDE",
              "MONEY",
              "ABSENT_APPEAL"
            ],
            "additionalProperties": false,
            "properties": {
              "ALL": {
                "type": "integer",
                "minimum": 0
              },
              "LEAVE": {
                "type": "integer",
                "minimum": 0
              },
              "OVERTIME": {
                "type": "integer",
                "minimum": 0
              },
              "REGULARIZATION": {
                "type": "integer",
                "minimum": 0
              },
              "CLAIM": {
                "type": "integer",
                "minimum": 0
              },
              "OUT_OF_ZONE": {
                "type": "integer",
                "minimum": 0
              },
              "GOAL": {
                "type": "integer",
                "minimum": 0,
                "description": "Always 0 today — see description above."
              },
              "MUSTER": {
                "type": "integer",
                "minimum": 0,
                "description": "Attendance musters (`attend.muster_rolls`). No PWA chip yet — see description above."
              },
              "REQUISITION": {
                "type": "integer",
                "minimum": 0,
                "description": "Recruit requisitions (`recruit.requisitions`). No PWA chip yet — see description above."
              },
              "OFFER": {
                "type": "integer",
                "minimum": 0,
                "description": "Recruit offers (`recruit.offers`). No PWA chip yet — see description above."
              },
              "FACE_ENROLMENT": {
                "type": "integer",
                "minimum": 0,
                "description": "Face-enrolment HR approvals (`attend.face_enrollments`, GAP-39/#967). Added by #1325."
              },
              "ABSENT_APPEAL": {
                "type": "integer",
                "minimum": 0,
                "description": "Absent appeals (`attend.late_entry_requests`, #1878) — an IN after the entry cutoff, decided FULL_DAY / HALF_DAY / REJECT by the reporting manager."
              },
              "PROMOTION": {
                "type": "integer",
                "minimum": 0,
                "description": "Promotion approvals (`engage.promotions`, ENG-F05/#1160). Added by #1325."
              },
              "TRAINING_NOMINATION": {
                "type": "integer",
                "minimum": 0,
                "description": "Training nominations (`learn.nomination`, LRN-F03). Added by #1325."
              },
              "TIMESHEET": {
                "type": "integer",
                "minimum": 0,
                "description": "Timesheet approvals (`work.timesheets`, WRK-F05). Added by #1325."
              },
              "RESIGNATION": {
                "type": "integer",
                "minimum": 0,
                "description": "Separation decisions (`exit.resignations`, EXT-F01). Added by #1331 — mapped in the same change that started producing them, so `ALL` never drifts from the sum of the named categories."
              },
              "BANK_CHANGE": {
                "type": "integer",
                "minimum": 0,
                "description": "Bank-account changes (`people.bank_accounts`, #1643). The decision `security-docs/04` §2 calls the payroll-redirection vector — MC-2, four-eyes against whoever entered the account."
              },
              "IDENTITY_CHANGE": {
                "type": "integer",
                "minimum": 0,
                "description": "Statutory-identity changes (`people.personal_info`, #1643) — PAN/Aadhaar/UAN/Iqama and the legal name and date of birth the same filings are keyed on."
              },
              "LOAN_ADVANCE": {
                "type": "integer",
                "minimum": 0,
                "description": "Salary advances AND employee loans (`pay.salary_advances`, `pay.employee_loans`, #1649) — the ONE category with two source types behind it, because an advance and a loan are the same decision taken by the same role on the same band. The two remain separately filterable through `?source_type=`."
              },
              "ESOP_EXERCISE": {
                "type": "integer",
                "minimum": 0,
                "description": "ESOP exercises (`pay.esop_exercises`, #1649) — MC-2 on the approve leg, and on an India legal entity the approver must also supply a reviewed withholding amount and reason."
              },
              "FNF": {
                "type": "integer",
                "minimum": 0,
                "description": "Full-and-final settlements (`pay.full_final_settlements`, #1649). The seeded `FNF` chain predates this by many migrations; `submit-for-approval` is what finally produces an envelope for it."
              },
              "FNF_OVERRIDE": {
                "type": "integer",
                "minimum": 0,
                "description": "Governed manual overrides of a calculated settlement (`pay.full_final_settlement_overrides`, #1649) — a separate decision from the settlement itself, under the separate `override_approve` grant #335 split out. Approve-only: pay publishes no rejection transition for an override."
              },
              "MONEY": {
                "type": "integer",
                "minimum": 0,
                "description": "Simple payroll months (`payroll.months`, #1782). Named from the tenant policy snapshot; no seeded ROLE chain."
              }
            }
          }
        }
      },
      "ApprovalDecisionConflictProblem": {
        "description": "The `409 STATE_TRANSITION_INVALID` `xc.approval_inbox.decide` raises on the **decide-race**: two approvers (a co-approver, or a delegate) both act on the same row, and the loser's `status !== PENDING` check fails. Extends `Problem` the same way `ValidationProblem` extends it with `errors[]` (`03-errors-pagination.md` §1) — a typed member on a specific problem occurrence, not a new `code`. `decided_by`/`decided_by_id` name who won the race so the client can render \"Already decided by <name>\" (design-pwa/05 §4) without a follow-up `GET /approval-inbox/{id}`. Both are OPTIONAL: they are populated only when the row's `decided_by` resolves to a live `people.employees` row (a soft-deleted or otherwise absent decider degrades to the plain `detail` text, never a spurious extra failure on top of the 409). **Disclosure boundary:** `xc.approval_inbox.decide` 404s (not 409s) any caller who is neither the row's `current_approver_id`/`effective_approver_id` nor — on a `REQUISITION`/`OFFER` row — role-eligible for it under `#1489` — so by the time this 409 can fire, the caller is already a co-approver of the SAME item, and the winner's name is information a co-approver of that item is already entitled to see, not a new exposure.\n",
        "allOf": [
          {
            "$ref": "#/components/schemas/Problem"
          },
          {
            "type": "object",
            "properties": {
              "decided_by": {
                "type": "string",
                "description": "The winning decider's display name (`people.employees.full_name`)."
              },
              "decided_by_id": {
                "type": "string",
                "format": "uuid",
                "description": "The winning decider's employee id."
              }
            }
          }
        ]
      },
      "ApprovalDecisionInput": {
        "type": "object",
        "required": [
          "decision"
        ],
        "additionalProperties": false,
        "properties": {
          "decision": {
            "type": "string",
            "enum": [
              "APPROVE",
              "REJECT"
            ]
          },
          "note": {
            "type": "string",
            "description": "Approver comment (`decision_note`)."
          },
          "action_input": {
            "type": "object",
            "additionalProperties": true,
            "description": "Owning-operation fields not common to every approval, e.g. `approved_hours` for overtime/timesheets; the owning service validates the exact shape. **`ABSENT_APPEAL`** (#1878): `{ \"outcome\": \"FULL_DAY\" | \"HALF_DAY\" }` on an APPROVE (FULL_DAY when omitted; anything else is a 422 at `/action_input/outcome`); a REJECT needs none."
          }
        }
      },
      "BulkApprovalDecisionInput": {
        "type": "object",
        "required": [
          "items"
        ],
        "additionalProperties": false,
        "properties": {
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 200,
            "items": {
              "type": "object",
              "required": [
                "id",
                "version",
                "decision"
              ],
              "additionalProperties": false,
              "properties": {
                "id": {
                  "$ref": "#/components/schemas/UuidRef"
                },
                "version": {
                  "type": "integer",
                  "description": "Per-row optimistic-concurrency check (the item's current ETag/`version`)."
                },
                "decision": {
                  "type": "string",
                  "enum": [
                    "APPROVE",
                    "REJECT"
                  ]
                },
                "note": {
                  "type": "string"
                },
                "action_input": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      },
      "BulkApprovalDecisionResult": {
        "type": "object",
        "required": [
          "data",
          "decided_count"
        ],
        "additionalProperties": false,
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ApprovalInboxItem"
            }
          },
          "decided_count": {
            "type": "integer",
            "minimum": 0
          }
        }
      },
      "DelegationType": {
        "type": "string",
        "enum": [
          "APPROVALS",
          "ACT_ON_BEHALF"
        ]
      },
      "DelegationStatus": {
        "type": "string",
        "enum": [
          "SCHEDULED",
          "ACTIVE",
          "EXPIRED",
          "REVOKED"
        ]
      },
      "Delegation": {
        "description": "xc.delegations — a date-bounded delegation of approvals/actions (db 14 §6).",
        "allOf": [
          {
            "type": "object",
            "required": [
              "id",
              "delegator_id",
              "delegatee_id",
              "delegation_type",
              "start_date",
              "end_date",
              "status"
            ],
            "properties": {
              "id": {
                "$ref": "#/components/schemas/UuidRef"
              },
              "delegator_id": {
                "$ref": "#/components/schemas/UuidRef"
              },
              "delegatee_id": {
                "$ref": "#/components/schemas/UuidRef"
              },
              "delegation_type": {
                "$ref": "#/components/schemas/DelegationType"
              },
              "scope_tokens": {
                "type": [
                  "array",
                  "null"
                ],
                "items": {
                  "type": "string"
                },
                "description": "Delegable permission tokens covered, e.g. `[\"leave.leave_application.approve\"]` (Security round catalogue)."
              },
              "request_types": {
                "type": [
                  "array",
                  "null"
                ],
                "items": {
                  "$ref": "#/components/schemas/ApprovalRequestType"
                },
                "description": "null = all delegable request types."
              },
              "start_date": {
                "$ref": "#/components/schemas/DateOnly"
              },
              "end_date": {
                "$ref": "#/components/schemas/DateOnly"
              },
              "status": {
                "$ref": "#/components/schemas/DelegationStatus"
              },
              "reason": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          },
          {
            "$ref": "#/components/schemas/AuditMetaRef"
          }
        ]
      },
      "DelegationCreate": {
        "type": "object",
        "required": [
          "delegatee_id",
          "delegation_type",
          "start_date",
          "end_date"
        ],
        "additionalProperties": false,
        "properties": {
          "delegator_id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "delegatee_id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "delegation_type": {
            "$ref": "#/components/schemas/DelegationType"
          },
          "scope_tokens": {
            "type": "array",
            "nullable": true,
            "description": "Omit or send `null` for an UNRESTRICTED delegation (every delegable scope). An empty array is not the same thing — it covers nothing.\n",
            "items": {
              "type": "string"
            }
          },
          "request_types": {
            "type": "array",
            "nullable": true,
            "description": "The subset of request types this delegation covers. Omit or send `null` to cover EVERY type — that is what `xc.resolve_approval_delegate` and the lifecycle sweep read as unrestricted. An empty array covers NOTHING, since both test `request_types ? <type>`.\n",
            "items": {
              "$ref": "#/components/schemas/ApprovalRequestType"
            }
          },
          "start_date": {
            "$ref": "#/components/schemas/DateOnly"
          },
          "end_date": {
            "$ref": "#/components/schemas/DateOnly"
          },
          "reason": {
            "type": "string"
          }
        }
      },
      "DelegationUpdate": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "scope_tokens": {
            "type": "array",
            "nullable": true,
            "description": "Omit or send `null` for an UNRESTRICTED delegation (every delegable scope). An empty array is not the same thing — it covers nothing.\n",
            "items": {
              "type": "string"
            }
          },
          "request_types": {
            "type": "array",
            "nullable": true,
            "description": "The subset of request types this delegation covers. Omit or send `null` to cover EVERY type — that is what `xc.resolve_approval_delegate` and the lifecycle sweep read as unrestricted. An empty array covers NOTHING, since both test `request_types ? <type>`.\n",
            "items": {
              "$ref": "#/components/schemas/ApprovalRequestType"
            }
          },
          "start_date": {
            "$ref": "#/components/schemas/DateOnly"
          },
          "end_date": {
            "$ref": "#/components/schemas/DateOnly"
          },
          "reason": {
            "type": "string"
          }
        }
      },
      "DelegationPage": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CursorPageRef"
          },
          {
            "type": "object",
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Delegation"
                }
              }
            }
          }
        ]
      },
      "ApprovalRuleStatus": {
        "type": "string",
        "enum": [
          "DRAFT",
          "PENDING_PUBLISH",
          "PUBLISHED",
          "SUPERSEDED"
        ],
        "description": "`DRAFT` is editable and not live; `PENDING_PUBLISH` awaits a different MC-1 approver; `PUBLISHED` is live; `SUPERSEDED` is retained only as immutable routing provenance. The router and SLA sweep read `PUBLISHED` rules only.\n"
      },
      "ApprovalChainStep": {
        "type": "object",
        "description": "One ordered step of `xc.approval_rules.chain`. Each step names a **relationship, never a named person** (ADR 0027, *Alternatives considered* — requester-picked approvers are an integrity hole). `ROLE:<catalog_key>` resolves against `admin.role_catalog` (e.g. `ROLE:finance`, `ROLE:division_manager`); resolution happens **at event time in the jobs/projection path, never on the read path**.\n",
        "required": [
          "step_no",
          "resolver"
        ],
        "additionalProperties": false,
        "properties": {
          "step_no": {
            "type": "integer",
            "minimum": 1,
            "description": "Contiguous from 1; no duplicate resolver in adjacent steps."
          },
          "resolver": {
            "type": "string",
            "pattern": "^(DIRECT_MANAGER|ORG_UNIT_HEAD|ROLE:[a-z][a-z0-9_]*)$",
            "description": "`DIRECT_MANAGER` · `ORG_UNIT_HEAD` · `ROLE:<catalog_key>`."
          }
        }
      },
      "ApprovalEscalationTarget": {
        "type": "object",
        "description": "`xc.approval_rules.escalation_target` — where a step breaching `step_timeout_hours` escalates. **Exactly one of the three kinds**, validated at publish: `NEXT_STEP` advances the chain, `RELATIONSHIP` names `DIRECT_MANAGER`/`ORG_UNIT_HEAD`, `ROLE` names an `admin.role_catalog` key.\n",
        "required": [
          "kind"
        ],
        "additionalProperties": false,
        "properties": {
          "kind": {
            "type": "string",
            "enum": [
              "NEXT_STEP",
              "RELATIONSHIP",
              "ROLE"
            ]
          },
          "relationship": {
            "type": "string",
            "enum": [
              "DIRECT_MANAGER",
              "ORG_UNIT_HEAD"
            ],
            "description": "Required when `kind = RELATIONSHIP`."
          },
          "role_key": {
            "type": "string",
            "description": "Required when `kind = ROLE`; `ref→admin.role_catalog`."
          }
        }
      },
      "ApprovalRule": {
        "description": "xc.approval_rules — the routing table the approval engine reads: per `(request_type, band)` the ordered chain, the step timeout and the escalation target (db 14 §6, ADR 0027 §(a)). It **routes and records only** — it adds no authorization semantics and is no new place to hold authority.\n",
        "allOf": [
          {
            "type": "object",
            "required": [
              "id",
              "request_type",
              "chain",
              "step_timeout_hours",
              "escalation_target",
              "status",
              "rule_version"
            ],
            "properties": {
              "id": {
                "$ref": "#/components/schemas/UuidRef"
              },
              "request_type": {
                "$ref": "#/components/schemas/ApprovalRequestType"
              },
              "band_min_amount": {
                "type": [
                  "string",
                  "null"
                ],
                "pattern": "^\\d{1,16}(\\.\\d{1,2})?$",
                "description": "Inclusive band floor as a decimal string (numeric(18,2)); null = unbounded below. Never a float."
              },
              "band_max_amount": {
                "type": [
                  "string",
                  "null"
                ],
                "pattern": "^\\d{1,16}(\\.\\d{1,2})?$",
                "description": "Inclusive band ceiling; null = unbounded above."
              },
              "currency_code": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/CurrencyCode"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "Required when either band bound is set. **Bands compare only within the request''s own currency** — there is no cross-currency normalization, and the editor says so inline (ADR 0027, *Consequences*).\n"
              },
              "chain": {
                "type": "array",
                "minItems": 1,
                "items": {
                  "$ref": "#/components/schemas/ApprovalChainStep"
                }
              },
              "step_timeout_hours": {
                "type": "integer",
                "minimum": 1,
                "default": 48,
                "description": "Per-step SLA before escalation, measured by the jobs-tier sweep (XC-F08). Tenant-configurable."
              },
              "escalation_target": {
                "$ref": "#/components/schemas/ApprovalEscalationTarget"
              },
              "status": {
                "$ref": "#/components/schemas/ApprovalRuleStatus"
              },
              "rule_version": {
                "type": "integer",
                "minimum": 1,
                "description": "Monotonic **per rule identity** — versioned/publishable config, mirroring `org.leave_policies`/`org.pay_structures`. Distinct from the standard optimistic-lock `version` on `AuditMeta`, which surfaces as the ETag (db 14 §6).\n"
              },
              "published_at": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/TimestampRef"
                  },
                  {
                    "type": "null"
                  }
                ]
              }
            }
          },
          {
            "$ref": "#/components/schemas/AuditMetaRef"
          },
          {
            "type": "object",
            "readOnly": true,
            "properties": {
              "is_seeded_default": {
                "type": "boolean",
                "description": "A seeded default reproducing today''s implicit routing — rendered read-only with a *\"Seeded default\"* badge until edited, and editing one mints a `DRAFT` version beside it rather than mutating the live row (`ADM-S09`, ADR 0027 §(a)).\n"
              },
              "chain_summary": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Grid-ready rendering of `chain`, e.g. `DIRECT_MANAGER → ROLE:finance`."
              },
              "source_rule_id": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/UuidRef"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "The immutable live version this draft corrects; its in-flight inbox rows keep that route."
              },
              "submitted_by": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/UuidRef"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "submitted_at": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/TimestampRef"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "approved_by": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/UuidRef"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "approved_at": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/TimestampRef"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "publish_note": {
                "type": [
                  "string",
                  "null"
                ],
                "maxLength": 1000
              },
              "publish_gate": {
                "$ref": "#/components/schemas/ApprovalRulePublishGate"
              }
            }
          }
        ]
      },
      "ApprovalRuleCreate": {
        "type": "object",
        "required": [
          "request_type",
          "chain",
          "escalation_target"
        ],
        "additionalProperties": false,
        "properties": {
          "source_rule_id": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/UuidRef"
              },
              {
                "type": "null"
              }
            ],
            "description": "Set when editing a published/seeded rule; the service mints a new DRAFT beside it."
          },
          "request_type": {
            "$ref": "#/components/schemas/ApprovalRequestType"
          },
          "band_min_amount": {
            "type": "string",
            "pattern": "^\\d{1,16}(\\.\\d{1,2})?$"
          },
          "band_max_amount": {
            "type": "string",
            "pattern": "^\\d{1,16}(\\.\\d{1,2})?$"
          },
          "currency_code": {
            "$ref": "#/components/schemas/CurrencyCode"
          },
          "chain": {
            "type": "array",
            "minItems": 1,
            "items": {
              "$ref": "#/components/schemas/ApprovalChainStep"
            }
          },
          "step_timeout_hours": {
            "type": "integer",
            "minimum": 1,
            "default": 48
          },
          "escalation_target": {
            "$ref": "#/components/schemas/ApprovalEscalationTarget"
          }
        }
      },
      "ApprovalRuleUpdate": {
        "type": "object",
        "description": "`DRAFT` only — a `PUBLISHED` row is immutable and a correction is a new version. `status` is deliberately absent: publishing is the separate `xc.approval_rule.publish` gate.",
        "minProperties": 1,
        "additionalProperties": false,
        "properties": {
          "band_min_amount": {
            "type": [
              "string",
              "null"
            ],
            "pattern": "^\\d{1,16}(\\.\\d{1,2})?$"
          },
          "band_max_amount": {
            "type": [
              "string",
              "null"
            ],
            "pattern": "^\\d{1,16}(\\.\\d{1,2})?$"
          },
          "currency_code": {
            "$ref": "#/components/schemas/CurrencyCode"
          },
          "chain": {
            "type": "array",
            "minItems": 1,
            "items": {
              "$ref": "#/components/schemas/ApprovalChainStep"
            }
          },
          "step_timeout_hours": {
            "type": "integer",
            "minimum": 1
          },
          "escalation_target": {
            "$ref": "#/components/schemas/ApprovalEscalationTarget"
          }
        }
      },
      "ApprovalRulePublishInput": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "note": {
            "type": "string",
            "description": "Captured to the audit trail (XC-F06) alongside the publish."
          }
        }
      },
      "ApprovalRulePublishGate": {
        "type": "object",
        "readOnly": true,
        "required": [
          "in_flight_count",
          "blockers",
          "next_action",
          "viewer_is_submitter"
        ],
        "properties": {
          "in_flight_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Pending/escalated inbox rows frozen to the live version this draft replaces."
          },
          "blockers": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Data-aware illegal-state reasons: overlap, invalid shape, vacant role, or maker=self."
          },
          "next_action": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "SUBMIT",
              "APPROVE",
              null
            ]
          },
          "viewer_is_submitter": {
            "type": "boolean"
          }
        }
      },
      "ApprovalRulePreviewInput": {
        "type": "object",
        "required": [
          "request_type",
          "requesting_employee_id"
        ],
        "additionalProperties": false,
        "properties": {
          "request_type": {
            "$ref": "#/components/schemas/ApprovalRequestType"
          },
          "amount": {
            "type": "string",
            "pattern": "^\\d{1,16}(\\.\\d{1,2})?$"
          },
          "currency_code": {
            "$ref": "#/components/schemas/CurrencyCode"
          },
          "requesting_employee_id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "draft_rule_id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "draft": {
            "allOf": [
              {
                "$ref": "#/components/schemas/ApprovalRuleCreate"
              }
            ],
            "description": "An unsaved draft to resolve; mutually exclusive with `draft_rule_id`."
          }
        }
      },
      "ApprovalRulePreviewSide": {
        "type": [
          "object",
          "null"
        ],
        "properties": {
          "matched": {
            "type": "boolean"
          },
          "unrouted": {
            "type": "boolean"
          },
          "rule_id": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/UuidRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "rule_version": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1
          },
          "status": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/ApprovalRuleStatus"
              },
              {
                "type": "null"
              }
            ]
          },
          "fallback_description": {
            "type": [
              "string",
              "null"
            ]
          },
          "steps": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "step_no": {
                  "type": "integer",
                  "minimum": 1
                },
                "resolver": {
                  "type": "string"
                },
                "resolved": {
                  "type": "boolean"
                },
                "approver": {
                  "type": [
                    "object",
                    "null"
                  ],
                  "additionalProperties": true
                },
                "holder_count": {
                  "type": "integer",
                  "minimum": 0
                },
                "delegation": {
                  "type": [
                    "object",
                    "null"
                  ],
                  "additionalProperties": true
                },
                "timeout_hours": {
                  "type": "integer",
                  "minimum": 1
                }
              }
            }
          },
          "escalation": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true
          },
          "findings": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "severity",
                "code",
                "message"
              ],
              "properties": {
                "severity": {
                  "type": "string",
                  "enum": [
                    "danger"
                  ]
                },
                "code": {
                  "type": "string"
                },
                "message": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "ApprovalRulePreview": {
        "type": "object",
        "required": [
          "sample",
          "side_effect_free",
          "published",
          "draft"
        ],
        "properties": {
          "sample": {
            "type": "object",
            "additionalProperties": true
          },
          "side_effect_free": {
            "type": "boolean",
            "const": true
          },
          "published": {
            "$ref": "#/components/schemas/ApprovalRulePreviewSide"
          },
          "draft": {
            "$ref": "#/components/schemas/ApprovalRulePreviewSide"
          }
        }
      },
      "UnroutedRequestType": {
        "type": "object",
        "description": "A request type with **no published rule** — its requests fall back to the owning module's historical default and their `xc.approval_inbox` rows are flagged `unrouted`. Returned so `ADM-S09` can surface it in a first-class banner rather than swallow it (ADR 0027 §(c)).\n",
        "readOnly": true,
        "properties": {
          "request_type": {
            "$ref": "#/components/schemas/ApprovalRequestType"
          },
          "fallback_description": {
            "type": "string",
            "description": "The module default those requests currently take."
          }
        }
      },
      "ApprovalRulePage": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CursorPageRef"
          },
          {
            "type": "object",
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/ApprovalRule"
                }
              },
              "unrouted": {
                "type": "array",
                "description": "The unrouted banner's contents — never omitted when non-empty.",
                "items": {
                  "$ref": "#/components/schemas/UnroutedRequestType"
                }
              }
            }
          }
        ]
      },
      "SearchEntityKind": {
        "type": "string",
        "enum": [
          "EMPLOYEE",
          "DOCUMENT",
          "TICKET",
          "REQUISITION",
          "POLICY",
          "ASSET",
          "OTHER"
        ]
      },
      "SearchResult": {
        "description": "xc.search_index — one indexed, permitted record (db 14 §7).",
        "type": "object",
        "required": [
          "target_type",
          "target_id",
          "entity_kind",
          "title"
        ],
        "additionalProperties": false,
        "properties": {
          "target_type": {
            "type": "string"
          },
          "target_id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "entity_kind": {
            "$ref": "#/components/schemas/SearchEntityKind"
          },
          "title": {
            "type": "string"
          },
          "subtitle": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "SearchResultPage": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CursorPageRef"
          },
          {
            "type": "object",
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/SearchResult"
                }
              }
            }
          }
        ]
      },
      "JobRunTrigger": {
        "type": "string",
        "enum": [
          "SCHEDULED",
          "EVENT",
          "MANUAL"
        ]
      },
      "JobRunStatus": {
        "type": "string",
        "enum": [
          "QUEUED",
          "RUNNING",
          "SUCCEEDED",
          "FAILED",
          "RETRYING",
          "CANCELLED"
        ]
      },
      "JobRun": {
        "description": "xc.job_runs — one async job execution (db 14 §7).",
        "type": "object",
        "required": [
          "id",
          "job_name",
          "trigger",
          "status",
          "attempts"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "job_name": {
            "type": "string",
            "description": "e.g. `payroll.execute`, `leave.accrual`, `iqama.expiry_scan`, `outbox.dispatch`."
          },
          "trigger": {
            "$ref": "#/components/schemas/JobRunTrigger"
          },
          "idempotency_key": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "$ref": "#/components/schemas/JobRunStatus"
          },
          "attempts": {
            "type": "integer",
            "minimum": 0
          },
          "scope_ref": {
            "type": [
              "object",
              "null"
            ],
            "description": "Job input scope, e.g. `{ payroll_run_id }` (db 14 §7)."
          },
          "result": {
            "type": [
              "object",
              "null"
            ],
            "description": "Summary outcome — `{ processed, failed, notes }` (db 14 §7)."
          },
          "started_at": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/TimestampRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "finished_at": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/TimestampRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "error": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "JobRunPage": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CursorPageRef"
          },
          {
            "type": "object",
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/JobRun"
                }
              }
            }
          }
        ]
      },
      "AccessLogActorType": {
        "type": "string",
        "enum": [
          "USER",
          "SYSTEM",
          "JOB",
          "PLATFORM"
        ]
      },
      "AccessLogEvent": {
        "type": "string",
        "enum": [
          "LOGIN",
          "LOGOUT",
          "LOGIN_FAILED",
          "MFA_CHALLENGE",
          "PERMISSION_DENIED",
          "DATA_ACCESS"
        ]
      },
      "AccessLogOutcome": {
        "type": "string",
        "enum": [
          "ALLOW",
          "DENY"
        ]
      },
      "AccessLogDataClass": {
        "type": "string",
        "enum": [
          "AADHAAR",
          "NATIONAL_ID",
          "IQAMA",
          "BANK",
          "SALARY",
          "MEDICAL",
          "PII_OTHER"
        ]
      },
      "AccessLogEntry": {
        "description": "audit.access_log — an immutable, partitioned auth/permission/privileged-access event (db 15 §3).",
        "type": "object",
        "required": [
          "id",
          "actor_type",
          "event",
          "outcome",
          "created_at"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "actor_id": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/UuidRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "actor_type": {
            "$ref": "#/components/schemas/AccessLogActorType"
          },
          "on_behalf_of_id": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/UuidRef"
              },
              {
                "type": "null"
              }
            ],
            "description": "Delegated/impersonated original principal (XC-F14)."
          },
          "event": {
            "$ref": "#/components/schemas/AccessLogEvent"
          },
          "outcome": {
            "$ref": "#/components/schemas/AccessLogOutcome"
          },
          "permission_token": {
            "type": [
              "string",
              "null"
            ]
          },
          "data_class": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/AccessLogDataClass"
              },
              {
                "type": "null"
              }
            ]
          },
          "entity_type": {
            "type": [
              "string",
              "null"
            ]
          },
          "entity_id": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/UuidRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "purpose": {
            "type": [
              "string",
              "null"
            ],
            "description": "Declared purpose of a privileged read (DPDP/PDPL purpose-limitation)."
          },
          "resource": {
            "type": [
              "string",
              "null"
            ]
          },
          "ip": {
            "type": [
              "string",
              "null"
            ]
          },
          "user_agent": {
            "type": [
              "string",
              "null"
            ]
          },
          "session_ref": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "$ref": "#/components/schemas/TimestampRef"
          }
        }
      },
      "AccessLogEntryPage": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CursorPageRef"
          },
          {
            "type": "object",
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/AccessLogEntry"
                }
              }
            }
          }
        ]
      },
      "DomainEvent": {
        "description": "audit.domain_events — the append-only, replayable inter-module event journal (db 15 §2).",
        "type": "object",
        "required": [
          "id",
          "event_type",
          "aggregate_type",
          "aggregate_id",
          "payload",
          "occurred_at",
          "created_at"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "event_type": {
            "type": "string",
            "description": "e.g. `candidate.hired`, `payslip.published`, `leave.encashed`, `config.changed`."
          },
          "aggregate_type": {
            "type": "string"
          },
          "aggregate_id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "legal_entity_id": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/UuidRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "payload": {
            "type": "object",
            "description": "PII-minimised event body — shape varies by `event_type` (db 15 §2)."
          },
          "occurred_at": {
            "$ref": "#/components/schemas/TimestampRef"
          },
          "idempotency_key": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_by": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/UuidRef"
              },
              {
                "type": "null"
              }
            ],
            "description": "The acting PRINCIPAL (`xc.identities.id`), not an employee id."
          },
          "created_by_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "ref→people.employees.full_name (created_by), by projection (issue #1640): the principal is walked to its subject employee, falling back to the identity's own email. `null` when neither resolves — the reader then names no actor rather than printing the id."
          },
          "created_at": {
            "$ref": "#/components/schemas/TimestampRef"
          }
        }
      },
      "DomainEventPage": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CursorPageRef"
          },
          {
            "type": "object",
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/DomainEvent"
                }
              }
            }
          }
        ]
      },
      "DecisionArtifactType": {
        "type": "string",
        "enum": [
          "PAYSLIP",
          "FORM16",
          "ESIGN_SIGNATURE",
          "STATUTORY_FILING",
          "OFFER_LETTER",
          "FNF_SETTLEMENT",
          "INTERVIEW_SCORECARD",
          "LETTER"
        ]
      },
      "DecisionArtifact": {
        "description": "audit.decision_artifacts — tamper-evident index of an immutable regulated HR artifact (db 15 §4).",
        "type": "object",
        "required": [
          "id",
          "artifact_type",
          "subject_type",
          "subject_id",
          "content_hash",
          "retained_until",
          "created_at"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "artifact_type": {
            "$ref": "#/components/schemas/DecisionArtifactType"
          },
          "actor_id": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/UuidRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "subject_type": {
            "type": "string",
            "description": "e.g. `pay.payslips`, `tax.form16`, `docs.esign_signatures`."
          },
          "subject_id": {
            "$ref": "#/components/schemas/UuidRef"
          },
          "subject_employee_id": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/UuidRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "storage_key": {
            "type": [
              "string",
              "null"
            ]
          },
          "content_hash": {
            "type": "string",
            "description": "SHA-256 — mandatory tamper-evidence."
          },
          "pack_version": {
            "type": [
              "integer",
              "null"
            ]
          },
          "retained_until": {
            "$ref": "#/components/schemas/DateOnly"
          },
          "created_at": {
            "$ref": "#/components/schemas/TimestampRef"
          }
        }
      },
      "DecisionArtifactPage": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CursorPageRef"
          },
          {
            "type": "object",
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/DecisionArtifact"
                }
              }
            }
          }
        ]
      },
      "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"
          }
        }
      },
      "Uuid": {
        "type": "string",
        "format": "uuid",
        "description": "UUIDv7 surrogate primary key (db-docs/00 §3). Never the business identifier."
      },
      "Timestamp": {
        "type": "string",
        "format": "date-time",
        "description": "timestamptz, serialized UTC ISO-8601. Presentation timezone is a client concern."
      },
      "DateOnly": {
        "type": "string",
        "format": "date",
        "description": "Calendar-only value (pay-period date, leave date, due date)."
      },
      "DecimalHours": {
        "type": "string",
        "pattern": "^-?\\d+(\\.\\d{1,2})?$",
        "description": "numeric(9,2) decimal hours/days as a string (OT hours, leave days). Never a float."
      },
      "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"
        }
      },
      "FileDownload": {
        "type": "object",
        "description": "Authorized file handle (db-docs/00 §14, xc.files). Bytes never transit the API — the backend mints a time-limited presigned URL after authorization. Clients never see storage keys or hold storage credentials; the presigned URL is never persisted.\n",
        "required": [
          "file_id",
          "file_name",
          "url",
          "expires_at"
        ],
        "properties": {
          "file_id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "file_name": {
            "type": "string"
          },
          "mime_type": {
            "type": "string"
          },
          "size_bytes": {
            "type": "integer"
          },
          "content_hash": {
            "type": [
              "string",
              "null"
            ],
            "description": "SHA-256",
            "present where tamper-evidence matters (payslips": null,
            "letters": null,
            "e-sign artifacts).": null
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Time-limited presigned URL."
          },
          "expires_at": {
            "$ref": "#/components/schemas/Timestamp"
          }
        }
      },
      "AuditMeta": {
        "type": "object",
        "description": "Standard mutable-entity columns (db-docs/00 §5). Read-only; present on every mutable read-model.",
        "readOnly": true,
        "properties": {
          "created_at": {
            "$ref": "#/components/schemas/Timestamp"
          },
          "created_by": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Uuid"
              },
              {
                "type": "null"
              }
            ],
            "description": "null = system/jobs"
          },
          "updated_at": {
            "$ref": "#/components/schemas/Timestamp"
          },
          "updated_by": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Uuid"
              },
              {
                "type": "null"
              }
            ]
          },
          "deleted_at": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Timestamp"
              },
              {
                "type": "null"
              }
            ],
            "description": "soft-delete marker; live rows are null. Deleted rows are excluded by default scope."
          },
          "version": {
            "type": "integer",
            "description": "Optimistic-lock counter (where present); surfaces as the ETag."
          }
        }
      },
      "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"
              }
            }
          }
        }
      },
      "Money": {
        "type": "object",
        "description": "Exact decimal money (db-docs/00 §6). `amount` is a STRING so float never enters the wire.",
        "required": [
          "amount",
          "currency_code"
        ],
        "additionalProperties": false,
        "properties": {
          "amount": {
            "type": "string",
            "pattern": "^-?\\d+(\\.\\d{1,2})?$",
            "description": "numeric(18,2) as a string, e.g. \"45000.00\"."
          },
          "currency_code": {
            "$ref": "#/components/schemas/CurrencyCode"
          }
        }
      },
      "CurrencyCode": {
        "type": "string",
        "enum": [
          "INR",
          "SAR"
        ],
        "description": "ISO-4217. A legal entity operates in a single currency (db-docs/00 §6)."
      },
      "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": {
      "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"
        }
      },
      "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"
        }
      },
      "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
        }
      },
      "IfMatch": {
        "name": "If-Match",
        "in": "header",
        "required": true,
        "description": "Optimistic-concurrency precondition for mutating a VERSIONED mutable entity (db-docs/00 §5 applies `version` where concurrent edits are likely). Value is the entity's current ETag (the row `version`). Absent → 428; stale → 412 (04 §2). N/A for append-only entities and for unversioned low-contention entities (their update ops simply omit this parameter).\n",
        "schema": {
          "type": "string"
        }
      },
      "CorrelationId": {
        "name": "X-Correlation-Id",
        "in": "header",
        "required": false,
        "description": "Caller-supplied trace id; echoed back. Generated if absent.",
        "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"
            }
          }
        }
      },
      "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"
            }
          }
        }
      },
      "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"
            }
          }
        }
      },
      "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."
                    }
                  ]
                }
              }
            }
          }
        }
      },
      "Locked": {
        "description": "Tenant subscription `past_due` (ADR 0009): WRITES are blocked (423), reads still succeed. `code` = TENANT_PAST_DUE. Mutations return this; list/get operations do not.\n",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Per-principal/route rate limit exceeded.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "PreconditionRequired": {
        "description": "If-Match header absent on a mutation of a versioned mutable entity (428).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "PreconditionFailed": {
        "description": "If-Match / ETag mismatch — the row changed since it was read (412, VERSION_CONFLICT).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      }
    },
    "headers": {
      "ETag": {
        "description": "Strong validator of the row `version` (e.g. `\"v7\"`). Use as `If-Match` on the next mutation.",
        "schema": {
          "type": "string"
        }
      },
      "Location": {
        "description": "URI of the created/affected resource.",
        "schema": {
          "type": "string",
          "format": "uri-reference"
        }
      },
      "IdempotencyReplayed": {
        "description": "`true` when a stored idempotent response was replayed rather than freshly computed.",
        "schema": {
          "type": "boolean"
        }
      },
      "RetryAfter": {
        "description": "Seconds to wait before retrying (on 429 / 503).",
        "schema": {
          "type": "integer"
        }
      }
    }
  }
}