openapi: 3.1.0
info:
  title: Substratal Apps — Platform API
  version: "1.0.0-draft.33"
  summary: >-
    User accounts, auth, roles, per-app entitlements (the on/off access
    switch), profiles, settings, organizations, tenancy, subscription billing,
    and push notifications for applications built on Substratal Apps. Target contract for an API
    that is not yet implemented; the prose specification is authoritative.
  description: >-
    Full documentation: https://docs.substratalapps.com/ — read
    /api-reference/conventions/ first (IDs, pagination, PATCH semantics,
    ETag/If-Match, idempotency, error shape).

    Every request must send the API version in a `Substratal-Version`
    header (currently `1`, the only version); every response echoes the
    version that served it. A missing header is `400 api_version_required`,
    and an unknown version is `400 unsupported_api_version`, both with
    `details.supported`. Exempt: `GET /health`, `POST /auth/oauth/token`
    (called by standard OAuth libraries), the `/.well-known` endpoints, and
    SCIM under `/scim/v2` (called by identity providers).
    Every versioned operation declares it as the shared `ApiVersion` header
    parameter, so generated clients know to send it. See
    /api-reference/conventions/#versioning.

    Vendor extensions used here:
    `x-substratal-destructive` (`always` | `conditional`) marks operations
    annotated destructiveHint for MCP and rejected for API Keys with
    restrict_destructive=true. For `conditional`, only requests matching
    `x-substratal-destructive-when` are rejected; the rest of the
    operation stays callable.
    `x-substratal-step-up` (`always` | `conditional`) marks operations
    that need a recent MFA check from a User session (Auth → Step-up):
    without one within the last 10 minutes they return 403
    reauthentication_required. For `conditional`, only requests matching
    `x-substratal-step-up-when` need it.
    Application and Role ids are derived (app_<slug>, role_<slug>_<name>),
    not random; every other id is a prefix plus a ULID. `x-substratal-phase` gives the roadmap phase an operation
    ships in.
  license:
    name: Unlicensed draft — internal specification, not yet released
externalDocs:
  description: Substratal Apps Platform API documentation
  url: https://docs.substratalapps.com/
servers:
  - url: https://api.substratalapps.com
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Auth
  - name: Users
  - name: Profiles
  - name: Settings
  - name: Applications
  - name: Entitlements
  - name: Roles & Permissions
  - name: Organizations
  - name: Tenancy
  - name: Billing
  - name: Audit
  - name: Usage & stats
    description: >-
      Request usage per Application, API Key, and operation, and the console home screen's live counts.
  - name: Webhooks
  - name: Notifications
    description: >-
      Push notifications (APNs, FCM, Web Push) and the notification inbox.
  - name: API Keys
  - name: Platform
    description: >-
      Runtime operational switches (signup, read-only maintenance) and client
      feature flags.
  - name: Meta

paths:
  /health:
    get:
      operationId: health.check
      tags: [Meta]
      summary: Health check
      security: []
      responses:
        "200":
          description: Service is responding
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok] }

  # ───────────────────────────── Auth ─────────────────────────────

  /auth/signup:
    post:
      operationId: auth.signup
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Create an account with email and password
      description: "An email held by an invited User is rejected with 409 email_taken (details.reason invitation_pending) and the invitation is re-sent; invitations complete only through invitations/accept."
      security: []
      x-substratal-phase: mvp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, format: password, minLength: 12, maxLength: 128 }
                display_name: { type: string, minLength: 1, maxLength: 100 }
                application_id: { type: string, description: "Informational: records which app the signup came from. Grants nothing." }
                test_mode: { type: boolean, default: false, description: "Create a test-mode User." }
                expires_at: { type: string, format: date-time, description: "Test mode only: when the account stops working. Default 24 hours from now; at least 15 minutes away, at most 30 days. 422 expires_at_out_of_range, or live_user when test_mode is false." }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "201":
          description: Account created and signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthSession" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /auth/login:
    post:
      operationId: auth.login
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Exchange email and password for a session (or an MFA challenge)
      security: []
      x-substratal-phase: mvp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, format: password }
                test_mode: { type: boolean, default: false, description: "Sign in the test-mode account with this email." }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A session, or an MFA challenge when mfa_required is true
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/AuthSession"
                  - $ref: "#/components/schemas/MfaChallenge"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /auth/mfa/verify:
    post:
      operationId: auth.verifyMfa
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Complete a login that returned an MFA challenge
      description: >-
        Send the mfa_token and exactly one of code (TOTP), recovery_code, or
        passkey (an assertion for options from POST /auth/mfa/passkey-options).
        A passkey sign-in adds hwk to the session's amr.
      security: []
      x-substratal-phase: mvp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mfa_token]
              properties:
                mfa_token: { type: string }
                code: { type: string, pattern: "^[0-9]{6}$" }
                recovery_code: { type: string }
                passkey: { $ref: "#/components/schemas/PasskeyAssertion" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Session issued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /auth/mfa/passkey-options:
    post:
      operationId: auth.mfaPasskeyOptions
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Get WebAuthn request options to complete an MFA challenge with a passkey
      description: >-
        Returns PublicKeyCredentialRequestOptions limited to the User's own
        passkeys, with a fresh challenge valid for 5 minutes and usable once.
        Pass publicKey to navigator.credentials.get() and send the result to
        POST /auth/mfa/verify as passkey, with the same mfa_token.
        409 passkey_not_registered when the User has no passkey.
      security: []
      x-substratal-phase: mvp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mfa_token]
              properties:
                mfa_token: { type: string }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Request options
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PasskeyRequestOptions" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /auth/token/refresh:
    post:
      operationId: auth.refreshToken
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Rotate a platform refresh token for a new access token
      security: []
      x-substratal-phase: mvp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: { type: string }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: New session tokens (the old refresh token is now invalid)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /auth/logout:
    post:
      operationId: auth.logout
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Revoke the current session, or every session
      x-substratal-phase: mvp
      x-substratal-destructive: conditional
      x-substratal-destructive-when: "all_sessions is true"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                all_sessions: { type: boolean, default: false }
      responses:
        "204": { description: Logged out }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/reauthenticate:
    post:
      operationId: auth.reauthenticate
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Prove a second factor again, for step-up (User token, MFA enabled)
      description: >-
        Records a fresh MFA check on the calling session (mfa_verified_at), so
        operations marked x-substratal-step-up work for the next 10 minutes.
        Send exactly one of code (a current TOTP code), recovery_code (a
        single-use recovery code), or passkey (an assertion for options from
        POST /auth/reauthenticate/passkey-options). The response is a new
        access token carrying the updated mfa_verified_at claim; the refresh
        token is unchanged. Wrong codes and failed assertions count toward a
        lockout: after 5 in 15 minutes it is 429 too_many_attempts.
        409 mfa_not_enabled when the User has neither TOTP nor a passkey.
      x-substratal-phase: mvp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                code: { type: string, pattern: "^[0-9]{6}$" }
                recovery_code: { type: string }
                passkey: { $ref: "#/components/schemas/PasskeyAssertion" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Re-authenticated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Reauthentication" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /auth/reauthenticate/passkey-options:
    post:
      operationId: auth.reauthenticatePasskeyOptions
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Get WebAuthn request options to re-authenticate with a passkey (User token)
      description: >-
        Like POST /auth/mfa/passkey-options, for the calling User's session:
        send the assertion to POST /auth/reauthenticate as passkey.
        409 passkey_not_registered when the User has no passkey.
      x-substratal-phase: mvp
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Request options
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PasskeyRequestOptions" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }

  /auth/app-tokens:
    post:
      operationId: auth.createAppToken
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Mint a per-app JWT from a platform session (embedded login)
      description: "Requires a User token. Fails with 403 embedded_login_not_allowed for a self-registered (third-party) Application, which must use the hosted flow; 403 entitlement_required unless the user's resolved access to the Application is active; 409 application_not_available unless it's approved; and 403 tenant_suspended if its Tenant is suspended."
      x-substratal-phase: mvp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [application_id]
              properties:
                application_id: { type: string }
                organization_id: { type: string, description: "Attribute org_id to this Organization's grant." }
      responses:
        "200":
          description: App token
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppToken" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /auth/oauth/authorization-codes:
    post:
      operationId: auth.createAuthorizationCode
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Mint a one-time authorization code for a hosted launch (PKCE)
      description: "Called by Substratal's hosted sign-in page on the signed-in user's behalf."
      x-substratal-phase: phase3
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [application_id, redirect_uri, code_challenge, code_challenge_method]
              properties:
                application_id: { type: string }
                redirect_uri: { type: string }
                code_challenge: { type: string, minLength: 43, maxLength: 128 }
                code_challenge_method: { type: string, enum: [S256] }
                state: { type: string, maxLength: 512 }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "201":
          description: Authorization code
          content:
            application/json:
              schema:
                type: object
                required: [code, expires_in, redirect_to]
                properties:
                  code: { type: string }
                  expires_in: { type: integer, example: 60 }
                  redirect_to: { type: string }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /auth/oauth/token:
    post:
      operationId: auth.exchangeOAuthToken
      tags: [Auth]
      summary: Exchange an authorization code or app refresh token for an app token
      description: "Public-client OAuth 2.0 token endpoint. Errors use the OAuth error shape ({error, error_description}), not the usual envelope."
      security: []
      x-substratal-phase: phase3
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OAuthTokenRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/OAuthTokenRequest" }
      responses:
        "200":
          description: App token and rotated app refresh token
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/AppToken"
                  - type: object
                    properties:
                      refresh_token: { type: string }
                      refresh_token_expires_in: { type: integer }
        "400":
          description: OAuth error
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }

  /auth/email/verify:
    post:
      operationId: auth.verifyEmail
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Confirm an email address from an emailed token
      security: []
      x-substratal-phase: mvp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        "204": { description: Verified }
        "400": { $ref: "#/components/responses/BadRequest" }

  /auth/email/verify/resend:
    post:
      operationId: auth.resendEmailVerification
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Re-send a verification email (always 202)
      security: []
      x-substratal-phase: mvp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                test_mode: { type: boolean, default: false }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "202": { description: Accepted }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /auth/password/forgot:
    post:
      operationId: auth.forgotPassword
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Send a password-reset email (always 202)
      security: []
      x-substratal-phase: mvp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                test_mode: { type: boolean, default: false }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "202": { description: Accepted }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /auth/password/reset:
    post:
      operationId: auth.resetPassword
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Set a new password from a reset token (revokes every session)
      security: []
      x-substratal-phase: mvp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, new_password]
              properties:
                token: { type: string }
                new_password: { type: string, format: password, minLength: 12, maxLength: 128 }
      responses:
        "204": { description: Password reset }
        "400": { $ref: "#/components/responses/BadRequest" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /auth/invitations/accept:
    post:
      operationId: auth.acceptInvitation
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Set a password on an invited account, activate it, and sign in
      security: []
      x-substratal-phase: mvp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, password]
              properties:
                token: { type: string }
                password: { type: string, format: password, minLength: 12, maxLength: 128 }
                display_name: { type: string, maxLength: 100 }
      responses:
        "200":
          description: Session issued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /auth/sso/{provider}/callback:
    post:
      operationId: auth.ssoCallback
      tags: [Auth]
      summary: Complete an SSO login (reserved — 501 until the broker integration ships)
      security: []
      x-substratal-phase: phase3
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - name: provider
          in: path
          required: true
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema: { type: object }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Session issued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthSession" }
        "501":
          description: not_implemented
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /users/{id}/password:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    post:
      operationId: auth.changePassword
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Change password given the current one (self only)
      x-substratal-phase: mvp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current_password, new_password]
              properties:
                current_password: { type: string, format: password }
                new_password: { type: string, format: password, minLength: 12, maxLength: 128 }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "204": { description: Changed; other sessions revoked }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /users/{id}/mfa/totp:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    post:
      operationId: auth.enrollMfaTotp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Begin TOTP enrollment (self only)
      description: >-
        A User who already has a passkey needs a recent MFA check (step-up),
        like registering another passkey.
      x-substratal-phase: mvp
      x-substratal-step-up: conditional
      x-substratal-step-up-when: "the User already has an MFA method"
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Pending TOTP secret
          content:
            application/json:
              schema:
                type: object
                properties:
                  secret: { type: string }
                  otpauth_uri: { type: string }
                  expires_in: { type: integer }
        "409": { $ref: "#/components/responses/Conflict" }
    delete:
      operationId: auth.resetMfaTotp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Support reset of a user's MFA, TOTP and passkeys (requires users.manage)
      description: >-
        Removes TOTP, every passkey, and the recovery codes, and revokes every
        session. 409 mfa_not_enabled when the User has neither.
      x-substratal-phase: mvp
      x-substratal-destructive: always
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "204": { description: MFA removed; sessions revoked }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /users/{id}/mfa/totp/confirm:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    post:
      operationId: auth.confirmMfaTotp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Confirm TOTP enrollment with a first code; returns recovery codes once
      description: >-
        recovery_codes is present only when TOTP is the User's first MFA
        method. A User who already has a passkey keeps their existing codes,
        and needs a recent MFA check (step-up).
      x-substratal-phase: mvp
      x-substratal-step-up: conditional
      x-substratal-step-up-when: "the User already has an MFA method"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, pattern: "^[0-9]{6}$" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Enabled
          content:
            application/json:
              schema:
                type: object
                properties:
                  mfa_enabled: { type: boolean }
                  recovery_codes: { type: array, items: { type: string }, minItems: 10, maxItems: 10 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }

  /users/{id}/mfa/totp/disable:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    post:
      operationId: auth.disableMfaTotp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Turn TOTP off given a current code or recovery code (self only)
      x-substratal-phase: mvp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: "Exactly one of code or recovery_code."
              properties:
                code: { type: string, pattern: "^[0-9]{6}$", description: "A current TOTP code" }
                recovery_code: { type: string }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "204": { description: Disabled }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /users/{id}/mfa/passkeys/registration-options:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    post:
      operationId: auth.passkeyRegistrationOptions
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Begin registering a passkey (self only)
      description: >-
        Returns PublicKeyCredentialCreationOptions (user verification required,
        resident key preferred, the User's existing passkeys excluded) with a
        challenge valid for 5 minutes and usable once. Pass publicKey to
        navigator.credentials.create() and send the result to
        POST /users/{id}/mfa/passkeys. A User who already has an MFA method
        needs a recent MFA check (step-up); one enrolling their first method
        needs a session signed in with a password in the last 10 minutes
        (403 recent_sign_in_required). 409 passkey_limit_reached at 10
        passkeys; 409 mfa_not_applicable without a password identity.
      x-substratal-phase: mvp
      x-substratal-step-up: conditional
      x-substratal-step-up-when: "the User already has an MFA method"
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Creation options
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PasskeyCreationOptions" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }

  /users/{id}/mfa/passkeys:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    get:
      operationId: auth.listPasskeys
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: List a User's passkeys (self or users.manage)
      description: "Oldest first. Never includes key material."
      x-substratal-phase: mvp
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The User's passkeys
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PasskeyList" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      operationId: auth.registerPasskey
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Finish registering a passkey (self only)
      description: >-
        Verifies the attestation against the challenge from
        registration-options, which must have been issued to this session,
        and stores the credential. MFA is then enabled. When this is the
        User's first MFA method the response also carries ten recovery codes,
        shown once. Writes user.passkey_added. 400 passkey_invalid when the
        attestation fails (wrong origin or RP ID, unknown, expired, or used
        challenge, user verification missing); 409 passkey_limit_reached;
        409 passkey_already_registered.
      x-substratal-phase: mvp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [credential]
              properties:
                credential: { $ref: "#/components/schemas/PasskeyAttestation" }
                name: { type: string, description: "A label for the User, at most 64 characters. Default Passkey." }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "201":
          description: Registered
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PasskeyRegistration" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /users/{id}/mfa/passkeys/{passkeyId}:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
      - { name: passkeyId, in: path, required: true, schema: { type: string } }
    patch:
      operationId: auth.updatePasskey
      tags: [Auth]
      summary: Rename a passkey (self only)
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, description: "1 to 64 characters." }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The updated passkey
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Passkey" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      operationId: auth.deletePasskey
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Remove a passkey (self only)
      description: >-
        Needs a recent MFA check (step-up). Writes user.passkey_removed.
        Removing the User's last MFA method (no TOTP, no other passkey) also
        deletes the recovery codes, writes user.mfa_disabled, and alerts the
        User, as turning TOTP off does.
      x-substratal-phase: mvp
      x-substratal-destructive: always
      x-substratal-step-up: always
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "204": { description: Removed }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /users/{id}/sessions:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    get:
      operationId: auth.listSessions
      tags: [Auth]
      summary: List active sessions (self or users.manage)
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of sessions
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PageEnvelope"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Session" } }

  /users/{id}/sessions/revoke-all:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    post:
      operationId: auth.revokeAllSessions
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Revoke every session (self or users.manage)
      description: >-
        Signs the User out everywhere: each live session is revoked with revoked_reason
        revoked_all, which ends its refresh tokens and app refresh tokens and deactivates
        the push devices it registered. keep_current keeps the calling session, and only
        applies to your own sessions. Writes user.sessions_revoked.
      x-substratal-phase: mvp
      x-substratal-destructive: always
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                keep_current: { type: boolean, default: false, description: "Self only; 422 self_only otherwise." }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                required: [revoked]
                properties:
                  revoked: { type: integer, description: "How many sessions were revoked." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /users/{id}/sessions/{sessionId}:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
      - name: sessionId
        in: path
        required: true
        schema: { type: string }
    delete:
      operationId: auth.revokeSession
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Auth]
      summary: Revoke one session
      x-substratal-phase: mvp
      x-substratal-destructive: always
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "204": { description: Revoked }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───────────────────────────── Users ─────────────────────────────

  /users:
    get:
      operationId: users.list
      tags: [Users]
      summary: List or search users
      description: >-
        Requires users.list. An app-scoped key with users.list is forced to application_id=<its app>,
        and any role_id it sends must be one of its own Application's Roles (403 role_scope_mismatch).
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Include"
        - { name: status, in: query, description: "deleted is allowed for users.manage only", schema: { type: string, enum: [active, invited, suspended, deleted] } }
        - { name: email, in: query, schema: { type: string, format: email } }
        - { name: q, in: query, schema: { type: string, minLength: 3 } }
        - { name: application_id, in: query, schema: { type: string } }
        - { name: organization_id, in: query, schema: { type: string } }
        - name: role_id
          in: query
          description: >-
            Users holding any of these Roles by explicit assignment. Repeatable. Implicit holders
            of an Application's default_app_role aren't listed.
          style: form
          explode: true
          schema: { type: array, items: { type: string }, maxItems: 20 }
        - { name: created_after, in: query, schema: { type: string, format: date-time } }
        - { name: created_before, in: query, schema: { type: string, format: date-time } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of users
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserPage" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: users.create
      tags: [Users]
      summary: Create (invite) a user
      description: "Requires users.manage. Also creates a default Profile and Settings and assigns the member platform role, in one transaction."
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                display_name: { type: string, maxLength: 100 }
                status: { type: string, enum: [invited, active], default: invited }
                send_invitation: { type: boolean, default: true }
                expires_at: { type: string, format: date-time, description: "Test-mode keys only: when the User stops working. Default 24 hours from now; at least 15 minutes away, at most 30 days. 422 expires_at_out_of_range, or live_user with a live key." }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "201":
          description: User created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /users/bulk-invite:
    post:
      operationId: users.bulkInvite
      tags: [Users]
      summary: Invite up to 25 users in one call (users.manage)
      description: >-
        Requires users.manage. Each email is POST /users with status invited and an invitation email,
        processed independently: one failing email doesn't stop the others, and each success writes its
        own user.created Audit Event. Each email counts against POST /users's rate limit (20/min), so
        emails past it get a per-item 429. The response is 200 with one result per email, in request
        order; an item's status is what POST /users would have returned (201, 409 email_taken,
        422 validation_failed, 429 rate_limited). application_id, if given, becomes each new User's
        signup_application_id and brands the invitation email. See /api-reference/conventions/#bulk-operations.
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [emails]
              properties:
                emails: { type: array, minItems: 1, maxItems: 25, items: { type: string } }
                application_id: { type: string }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: One result per email, in request order
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BulkInviteResults" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /users/bulk-suspend:
    post:
      operationId: users.bulkSuspend
      tags: [Users]
      summary: Suspend up to 25 users in one call (users.manage)
      description: >-
        Requires users.manage. Each id is POST /users/{id}/suspend with the shared reason, processed
        independently, each with its own user.suspended Audit Event and webhooks. The protected
        superadmin is a per-item 409 user_protected. Like POST /users/{id}/suspend it needs a recent
        MFA check (403 reauthentication_required for the whole batch). Each id counts against
        POST /users/{id}/suspend's rate limit. The response is 200 with one result per id, in request order; an item's status is
        what POST /users/{id}/suspend would have returned. See /api-reference/conventions/#bulk-operations.
      x-substratal-phase: mvp
      x-substratal-destructive: always
      x-substratal-step-up: always
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_ids]
              properties:
                user_ids: { type: array, minItems: 1, maxItems: 25, items: { type: string } }
                reason: { type: string, maxLength: 2000 }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: One result per id, in request order
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BulkResults" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /users/{id}:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    get:
      operationId: users.get
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Users]
      summary: Fetch one user
      x-substratal-phase: mvp
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The user
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: users.update
      tags: [Users]
      summary: Update email (self, pending verification) or status (users.manage)
      x-substratal-phase: mvp
      x-substratal-destructive: conditional
      x-substratal-destructive-when: "status becomes suspended"
      x-substratal-step-up: conditional
      x-substratal-step-up-when: "status becomes suspended"
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, format: email }
                status: { type: string, enum: [active, suspended], description: "users.manage only. active on a deleted User restores it." }
                expires_at: { type: string, format: date-time, description: "users.manage only, test-mode Users only. At least 15 minutes from now and at most 30 days after created_at. Moving it later on an expired User brings them back and fires access.granted (test_user_extended)." }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Updated user
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      operationId: users.delete
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Users]
      summary: Soft-delete a user (self or users.manage)
      x-substratal-phase: mvp
      x-substratal-destructive: always
      x-substratal-step-up: always
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "204": { description: Deleted }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /users/{id}/suspend:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    post:
      operationId: users.suspend
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Users]
      summary: Suspend a user (users.manage)
      x-substratal-phase: mvp
      x-substratal-destructive: always
      x-substratal-step-up: always
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 2000 }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: User suspended
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }

  /users/{id}/invitation:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    post:
      operationId: users.resendInvitation
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Users]
      summary: Re-send the invitation email to an invited user (users.manage)
      x-substratal-phase: mvp
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "202": { description: Sent }
        "409": { $ref: "#/components/responses/Conflict" }

  /users/{id}/export:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    get:
      operationId: users.export
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Users]
      summary: Export everything this API holds about this user
      description: "Self, or users.manage. GDPR Article 20 / CCPA right-to-know. Covers every table the hard-delete cascade clears, plus global Settings, which the cascade leaves in place."
      x-substratal-phase: mvp
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Full data export bundle
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserExport" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /users/{id}/erasure-requests:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    post:
      operationId: users.requestErasure
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Users]
      summary: Request right-to-erasure (soft-delete now, hard-delete cascade in 7 days)
      x-substratal-phase: mvp
      x-substratal-destructive: always
      x-substratal-step-up: always
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 2000 }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "202":
          description: Scheduled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErasureRequest" }

  /users/{id}/erasure-requests/current:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    get:
      operationId: users.getErasure
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Users]
      summary: Read the User's pending erasure request
      description: "Self, or users.manage. Returns the erasure request while it's scheduled; 404 erasure_not_scheduled when none is pending (never requested, cancelled, or completed). A soft-deleted User 404s (user_not_found) for anyone without users.manage, as on cancel."
      x-substratal-phase: mvp
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The scheduled erasure request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErasureRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: users.cancelErasure
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Users]
      summary: Cancel a scheduled erasure inside its 7-day window (users.manage)
      x-substratal-phase: mvp
      x-substratal-destructive: always
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "204": { description: Cancelled }
        "404": { $ref: "#/components/responses/NotFound" }

  # ─────────────────────────── Profiles & Settings ───────────────────────────

  /users/{id}/profile:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    get:
      operationId: profiles.get
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Profiles]
      summary: Fetch global Profile
      x-substratal-phase: mvp
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The global Profile
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Profile" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: profiles.update
      tags: [Profiles]
      summary: Update global Profile (self or users.manage)
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProfileWrite" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Updated Profile
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Profile" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /users/{id}/avatar/uploads:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    post:
      operationId: profiles.createAvatarUpload
      tags: [Profiles]
      summary: Start an avatar upload (self or users.manage)
      description: >-
        Returns a presigned POST: send the image straight to url as multipart/form-data, with every
        entry of fields and then the file as the last field, named file, within 5 minutes. The API
        never sees the bytes. Then call POST /users/{id}/avatar/uploads/{uploadId}/complete. PNG,
        JPEG, or WebP, at most 2 MB (a larger content_length is 422 validation_failed). 10 upload
        starts per caller per hour, shared with Application icons.
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ImageUploadRequest" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "201":
          description: Where and how to upload
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImageUpload" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /users/{id}/avatar/uploads/{uploadId}/complete:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
      - { name: uploadId, in: path, required: true, schema: { type: string } }
    post:
      operationId: profiles.completeAvatarUpload
      tags: [Profiles]
      summary: Check an uploaded avatar and make it the Profile's avatar_url (self or users.manage)
      description: >-
        Checks the uploaded file's magic bytes against the declared content_type, its size, and its
        dimensions (at most 2048 × 2048), stores it at a content-addressed
        https://assets.substratalapps.com/avatars/… URL, sets avatar_url, and deletes the previous
        hosted avatar. A file that fails a check is deleted, and the call is 422 invalid_image with
        details.reason. An upload that was never sent, or was already completed, is 404
        upload_not_found. Writes profile.avatar_changed when someone other than the User does it.
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The updated Profile
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Profile" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /users/{id}/avatar:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    delete:
      operationId: profiles.deleteAvatar
      tags: [Profiles]
      summary: Remove the avatar (self or users.manage)
      description: >-
        Sets avatar_url to null and deletes the hosted image, if it is one; an external https URL is
        just cleared. PATCH /users/{id}/profile with avatar_url null does the same. Writes
        profile.avatar_changed when someone other than the User does it.
      x-substratal-phase: mvp
      x-substratal-destructive: always
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "204": { description: Removed }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /users/{id}/settings:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    get:
      operationId: settings.get
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Settings]
      summary: Fetch global Settings (self, or platform users.list)
      x-substratal-phase: mvp
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The global Settings
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Settings" }
    patch:
      operationId: settings.update
      tags: [Settings]
      summary: Update global Settings (self or users.manage)
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SettingsWrite" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Updated Settings
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Settings" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /users/{id}/apps/{appId}/profile:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
      - $ref: "#/components/parameters/AppId"
    get:
      operationId: appProfiles.get
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Profiles]
      summary: Fetch per-app AppProfile (self, the app's own key, or platform users.list; defaults if none written yet)
      x-substratal-phase: phase2
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The AppProfile
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppProfile" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: appProfiles.update
      tags: [Profiles]
      summary: Update per-app AppProfile (custom is merged one level; null removes a key)
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                display_handle: { type: [string, "null"], minLength: 1, maxLength: 64 }
                custom: { type: object }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Updated AppProfile
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppProfile" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /users/{id}/apps/{appId}/settings:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
      - $ref: "#/components/parameters/AppId"
    get:
      operationId: appSettings.get
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Settings]
      summary: Fetch fully resolved per-app settings (self, the app's own key, or platform users.list)
      x-substratal-phase: phase2
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Resolved AppSettings
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppSettingsResolved" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: appSettings.update
      tags: [Settings]
      summary: Write per-app overrides (validated against settings_schema; null clears)
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [overrides]
              properties:
                overrides: { type: object }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Resolved AppSettings after the write
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppSettingsResolved" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  # ───────────────────────────── Applications ─────────────────────────────

  /applications:
    get:
      operationId: applications.list
      tags: [Applications]
      summary: List the catalog (filtered by visibility rules)
      description: >-
        Newest first by created_at, ties broken by id. With applications.manage,
        review_status=pending_review is the review queue and defaults to oldest-waiting first, by
        review_requested_at (falling back to created_at). sort sets the order explicitly by
        created_at: created_at for oldest first, -created_at for newest first, in the queue too. A
        cursor is only valid under the sort it was issued with.
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Include"
        - { name: visibility, in: query, schema: { $ref: "#/components/schemas/ApplicationVisibility" } }
        - { name: review_status, in: query, schema: { $ref: "#/components/schemas/ApplicationReviewStatus" } }
        - { name: access_tier, in: query, schema: { $ref: "#/components/schemas/ApplicationAccessTier" } }
        - { name: owned, in: query, schema: { type: boolean } }
        - { name: tenant_id, in: query, schema: { type: string } }
        - { name: owner_user_id, in: query, schema: { type: string } }
        - { name: owner_organization_id, in: query, schema: { type: string } }
        - { name: q, in: query, description: "Case-insensitive prefix match on name or slug", schema: { type: string } }
        - { name: include_inactive, in: query, description: "Include retired Applications (also included by review_status=retired)", schema: { type: boolean, default: false } }
        - { name: sort, in: query, description: "created_at (oldest first) or -created_at (newest first). Ties are broken by id in the same direction.", schema: { type: string, enum: [created_at, -created_at] } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "200":
          description: A page of Applications (trimmed view)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApplicationSummaryPage" }
    post:
      operationId: applications.create
      tags: [Applications]
      summary: Register an Application (applications.manage, or self-service into review)
      description: >-
        applications.manage creates any Application, approved by default (concierge onboarding).
        Any other User with a verified email may submit their own: it's owned by the caller (or an
        Organization they're an org_admin of), starts in pending_review, and can't set visibility,
        review_status, or review_notes (403 moderation_field_forbidden). Its access_tier is free or
        restricted (the default); standard is 422 access_tier_reserved. Unverified
        email is 403 email_not_verified; API Keys without applications.manage are 403 forbidden.
        Self-service submissions are limited to 10 per User per day.
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/ApplicationWrite"
                - type: object
                  required: [slug, name, launch_url]
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "201":
          description: Application created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Application" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /applications/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: applications.get
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Applications]
      summary: Fetch one Application
      x-substratal-phase: mvp
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The Application
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Application" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: applications.update
      tags: [Applications]
      summary: Update a catalog entry (owner — configuration; applications.manage — anything)
      x-substratal-phase: mvp
      x-substratal-destructive: conditional
      x-substratal-destructive-when: "review_status becomes rejected, suspended, or retired, or available_app_roles/permissions shrink"
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ApplicationWrite" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Updated Application
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Application" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
    delete:
      operationId: applications.delete
      tags: [Applications]
      summary: Delete an unreferenced test-mode Application (applications.manage)
      description: >-
        Only a test-mode Application that nothing references can be deleted; a live one is
        409 application_not_deletable (retire it with review_status: retired instead). Anything
        still referencing it (Entitlements, API Keys, webhooks, devices, notifications, settings or
        profile rows, push credentials, role assignments, refresh tokens, Users who signed up
        through it) is 409 application_in_use, naming each kind in `references`. Its own Roles go
        with it; audit history is kept. Writes application.deleted.
      x-substratal-phase: mvp
      x-substratal-destructive: always
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "204": { description: Deleted }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /applications/{id}/icon/uploads:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: applications.createIconUpload
      tags: [Applications]
      summary: Start an icon upload (the owner or applications.manage)
      description: >-
        Returns a presigned POST, as for avatars: send the image straight to url with every entry of
        fields and then the file as the last field, named file, within 5 minutes, then call
        POST /applications/{id}/icon/uploads/{uploadId}/complete. PNG, JPEG, or WebP, square, at most
        1 MB (a larger content_length is 422 validation_failed). 10 upload starts per caller per
        hour, shared with avatars.
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ImageUploadRequest" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "201":
          description: Where and how to upload
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImageUpload" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /applications/{id}/icon/uploads/{uploadId}/complete:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: uploadId, in: path, required: true, schema: { type: string } }
    post:
      operationId: applications.completeIconUpload
      tags: [Applications]
      summary: Check an uploaded icon and make it the Application's icon_url (the owner or applications.manage)
      description: >-
        Checks the uploaded file's magic bytes against the declared content_type, its size, and its
        dimensions (square, at most 2048 × 2048), stores it at a content-addressed
        https://assets.substratalapps.com/apps/… URL, sets icon_url, and deletes the previous hosted
        icon. A file that fails a check is deleted, and the call is 422 invalid_image with
        details.reason. An upload that was never sent, or was already completed, is 404
        upload_not_found. Writes application.icon_changed.
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The updated Application
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Application" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /applications/{id}/icon:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      operationId: applications.deleteIcon
      tags: [Applications]
      summary: Remove the icon (the owner or applications.manage)
      description: >-
        Sets icon_url to null and deletes the hosted image, if it is one; an external https URL is
        just cleared. Writes application.icon_changed.
      x-substratal-phase: mvp
      x-substratal-destructive: always
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "204": { description: Removed }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───────────────────────────── Entitlements ─────────────────────────────

  /users/{id}/entitlements:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    get:
      operationId: entitlements.listForUser
      tags: [Entitlements]
      summary: A user's entitlements, personal and org-sourced
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Include"
        - { name: application_id, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { $ref: "#/components/schemas/EntitlementStatus" } }
        - { name: source, in: query, schema: { $ref: "#/components/schemas/EntitlementSource" } }
        - { name: include_inactive, in: query, schema: { type: boolean, default: false } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of Entitlements
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EntitlementPage" }
    post:
      operationId: entitlements.grant
      tags: [Entitlements]
      summary: Grant a personal Entitlement
      description: "Requires entitlements.manage (platform, or app-confined to the key's own Application), or the Application's owner."
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [application_id, source]
              properties:
                application_id: { type: string }
                source: { type: string, enum: [purchase, trial, admin_grant] }
                order_id: { type: [string, "null"], maxLength: 255 }
                starts_at: { type: string, format: date-time }
                ends_at: { type: [string, "null"], format: date-time }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "201":
          description: Entitlement created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Entitlement" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /entitlements:
    get:
      operationId: entitlements.list
      tags: [Entitlements]
      summary: List entitlements across users (entitlements.manage or the Application's owner; app keys and owners confined)
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Include"
        - { name: application_id, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { $ref: "#/components/schemas/EntitlementStatus" } }
        - { name: user_id, in: query, schema: { type: string } }
        - { name: organization_id, in: query, schema: { type: string } }
        - { name: source, in: query, schema: { $ref: "#/components/schemas/EntitlementSource" } }
        - { name: order_id, in: query, schema: { type: string } }
        - { name: ends_before, in: query, schema: { type: string, format: date-time } }
        - { name: include_inactive, in: query, schema: { type: boolean, default: false } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of Entitlements
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EntitlementPage" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /entitlements/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: entitlements.get
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Entitlements]
      summary: Fetch one entitlement
      x-substratal-phase: mvp
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The Entitlement
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Entitlement" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: entitlements.update
      tags: [Entitlements]
      summary: "The toggle: change status, term, source (trial→purchase), order_id, or an org grant's member scope"
      x-substratal-phase: mvp
      x-substratal-destructive: conditional
      x-substratal-destructive-when: "status becomes disabled or revoked"
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status: { $ref: "#/components/schemas/EntitlementStatus" }
                disabled_reason: { type: [string, "null"], maxLength: 255 }
                ends_at: { type: [string, "null"], format: date-time }
                source: { type: string, enum: [purchase] }
                order_id: { type: string, maxLength: 255 }
                member_scope: { $ref: "#/components/schemas/EntitlementMemberScope" }
                member_overrides: { type: array, maxItems: 1000, items: { type: string } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Updated Entitlement
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Entitlement" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      operationId: entitlements.delete
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Entitlements]
      summary: Hard-remove a grant made in error (no order_id, under 24 hours old)
      x-substratal-phase: mvp
      x-substratal-destructive: always
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "204": { description: Removed }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /entitlements/bulk-update:
    post:
      operationId: entitlements.bulkUpdate
      tags: [Entitlements]
      summary: Change the status of up to 25 Entitlements in one call
      description: >-
        Each item is PATCH /entitlements/{id} with its status and disabled_reason: the same access
        rules and transition table, processed independently, each with its own Audit Event and webhooks.
        Each item counts against PATCH /entitlements/{id}'s rate limit. The response is 200 with one
        result per item, in request order; an item's status is what the PATCH would have returned. An
        item that disables or revokes is refused (403 destructive_operation_restricted) for a
        restrict_destructive key; the other items go ahead. See /api-reference/conventions/#bulk-operations.
      x-substratal-phase: mvp
      x-substratal-destructive: conditional
      x-substratal-destructive-when: "an item's status becomes disabled or revoked"
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 25
                  items:
                    type: object
                    required: [id, status]
                    properties:
                      id: { type: string }
                      status: { $ref: "#/components/schemas/EntitlementStatus" }
                      disabled_reason: { type: [string, "null"], maxLength: 255 }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: One result per item, in request order
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BulkResults" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  # ─────────────────────────── Roles & Permissions ───────────────────────────

  /permissions:
    get:
      operationId: permissions.list
      tags: [Roles & Permissions]
      summary: List every Permission key the caller can see
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - { name: scope, in: query, description: "'platform' or an application_id", schema: { type: string } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of Permissions
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PermissionPage" }

  /roles:
    get:
      operationId: roles.list
      tags: [Roles & Permissions]
      summary: List Roles
      description: >-
        Any authenticated caller; each sees only the Roles visible to it. Platform Roles are
        visible to Users holding any platform permission and to platform keys. An Application's
        Roles are visible to platform roles.manage holders, the Application's own key, and the
        Application's owner (its owner_user_id, or any active org_admin of its
        owner_organization_id), with no platform permission needed. An owner sees only the Roles
        of Applications they own: never another Application's, and never platform Roles unless
        they also hold a platform permission. A scope the caller can't see returns an empty page,
        not an error.
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Include"
        - { name: scope, in: query, description: "'platform' or an application_id", schema: { type: string } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of Roles
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RolePage" }
    post:
      operationId: roles.create
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Roles & Permissions]
      summary: Define a Role
      x-substratal-phase: mvp
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RoleCreate" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "201":
          description: Role created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Role" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /roles/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: roles.get
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Roles & Permissions]
      summary: Fetch one Role
      x-substratal-phase: mvp
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The Role
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Role" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: roles.update
      tags: [Roles & Permissions]
      summary: Change a Role's permissions or description
      description: >-
        An app Role may be changed by platform roles.manage, the Application's own key holding
        roles.manage, or the Application's owner (its owner_user_id, or any active org_admin of
        its owner_organization_id) with no platform permission needed. Its permissions must be
        keys its own Application declares; a platform key, another Application's key, or an
        undeclared key returns 422 unknown_permission. A platform Role needs platform
        roles.manage, and every permission it grants must be one the caller holds (403
        role_escalation_forbidden); seed Roles can't be changed (403 seed_role_immutable). A
        Role the caller can't see (another Application's, or a platform Role for a caller with
        no platform permission) returns 404 role_not_found; one it can see but not change
        returns 403 forbidden.
      x-substratal-phase: mvp
      x-substratal-destructive: conditional
      x-substratal-destructive-when: "permissions shrink"
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                description: { type: [string, "null"], maxLength: 2000 }
                permissions: { type: array, items: { type: string } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Updated Role
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Role" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      operationId: roles.delete
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Roles & Permissions]
      summary: Delete an unassigned, non-seed Role
      x-substratal-phase: mvp
      x-substratal-destructive: always
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "204": { description: Deleted }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }

  /users/{id}/roles:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    get:
      operationId: roles.listForUser
      tags: [Roles & Permissions]
      summary: List a user's Role assignments (including implicit default_app_role)
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - { name: application_id, in: query, schema: { type: string } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of UserRoleAssignments
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserRoleAssignmentPage" }

  /users/{id}/roles/{roleId}:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
      - { name: roleId, in: path, required: true, schema: { type: string } }
    post:
      operationId: roles.assign
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Roles & Permissions]
      summary: Assign a Role (idempotent — 200 if already held)
      description: >-
        Assigning a platform Role that grants a permission to a User without
        MFA succeeds, but the Role takes effect only once they enroll TOTP;
        the response's mfa_enrollment_required says so (Auth → Staff MFA).
      x-substratal-phase: mvp
      x-substratal-step-up: conditional
      x-substratal-step-up-when: "the Role is a platform Role"
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Already assigned
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserRoleAssignment" }
        "201":
          description: Role assigned
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserRoleAssignment" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: roles.remove
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Roles & Permissions]
      summary: Remove a Role assignment (idempotent)
      x-substratal-phase: mvp
      x-substratal-destructive: always
      x-substratal-step-up: conditional
      x-substratal-step-up-when: "the Role is a platform Role"
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "204": { description: Removed }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }

  /users/{id}/apps/{appId}/effective-permissions:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
      - $ref: "#/components/parameters/AppId"
    get:
      operationId: permissions.getEffective
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Roles & Permissions]
      summary: The live access check (always 200)
      x-substratal-phase: phase2
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Effective permissions
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EffectivePermissions" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───────────────────────────── Organizations ─────────────────────────────

  /organizations:
    get:
      operationId: organizations.list
      tags: [Organizations]
      summary: List your Organizations (all, with organizations.manage)
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Include"
        - { name: status, in: query, schema: { type: string, enum: [active, suspended] } }
        - { name: q, in: query, schema: { type: string } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of Organizations
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationPage" }
    post:
      operationId: organizations.create
      tags: [Organizations]
      summary: Create an Organization (any active User; caller becomes org_admin)
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 100 }
      responses:
        "201":
          description: Organization created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Organization" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /organizations/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: organizations.get
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Organizations]
      summary: Fetch one Organization
      x-substratal-phase: phase2
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The Organization
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Organization" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: organizations.update
      tags: [Organizations]
      summary: Rename (org admin), or suspend/reactivate (organizations.manage)
      x-substratal-phase: phase2
      x-substratal-destructive: conditional
      x-substratal-destructive-when: "status becomes suspended"
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 1, maxLength: 100 }
                status: { type: string, enum: [active, suspended] }
                reason: { type: string, maxLength: 2000 }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Updated Organization
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Organization" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /organizations/{id}/members:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: organizations.listMembers
      tags: [Organizations]
      summary: List members
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Include"
        - { name: role, in: query, schema: { $ref: "#/components/schemas/OrganizationMemberRole" } }
        - { name: membership_status, in: query, schema: { $ref: "#/components/schemas/OrganizationMembershipStatus" } }
        - { name: q, in: query, schema: { type: string } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of members
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationMemberPage" }
    post:
      operationId: organizations.addMember
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Organizations]
      summary: Invite a member by email (org admin), or add one directly by user_id (organizations.manage)
      description: "By email, creates a pending membership (and an invited User if none exists) and emails an invitation; the response is identical whether or not the email already had an account. By user_id (organizations.manage only), the membership is active immediately. Never fails on a plan limit."
      x-substratal-phase: phase2
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id: { type: string }
                email: { type: string, format: email }
                role: { $ref: "#/components/schemas/OrganizationMemberRole" }
              oneOf:
                - required: [user_id]
                - required: [email]
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "201":
          description: Member invited (pending) or added (active)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationMember" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /organizations/{id}/members/me/accept:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: organizations.acceptMembership
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Organizations]
      summary: Accept a pending membership (the invited User's own token)
      x-substratal-phase: phase2
      responses:
        "200":
          description: The member, now active
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationMember" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /organizations/{id}/members/{userId}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: userId, in: path, required: true, description: "A usr_ id, or 'me' to leave", schema: { type: string } }
    patch:
      operationId: organizations.updateMember
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Organizations]
      summary: Change a member's role
      x-substratal-phase: phase2
      x-substratal-destructive: conditional
      x-substratal-destructive-when: "demotes an org_admin"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role: { $ref: "#/components/schemas/OrganizationMemberRole" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Updated member
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationMember" }
        "409": { $ref: "#/components/responses/Conflict" }
    delete:
      operationId: organizations.removeMember
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Organizations]
      summary: Remove a member or withdraw an invitation; or leave or decline (userId = me)
      x-substratal-phase: phase2
      x-substratal-destructive: always
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "204": { description: Removed }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }

  /organizations/{id}/scim-token:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: organizations.getScimToken
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Organizations]
      summary: Show the Organization's SCIM token (never its secret)
      description: "org_admin or organizations.manage. 404 scim_token_not_found when SCIM isn't set up."
      x-substratal-phase: phase3
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The token
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ScimToken" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      operationId: organizations.createScimToken
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Organizations]
      summary: Create (or rotate) the Organization's SCIM token
      description: >-
        org_admin or organizations.manage, with a User token. Returns the secret once, for the identity
        provider's SCIM settings, with base_url. Replaces any existing token at once. Writes
        organization.scim_token_created.
      x-substratal-phase: phase3
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "201":
          description: The new token, with its secret
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ScimToken" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: organizations.deleteScimToken
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Organizations]
      summary: Turn SCIM off for the Organization
      description: "org_admin or organizations.manage. The token stops working at once. Writes organization.scim_token_revoked."
      x-substratal-phase: phase3
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "204": { description: Revoked }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /organizations/{id}/entitlements:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: organizations.listEntitlements
      tags: [Organizations]
      summary: List org-wide (seat) grants
      description: Same filters as GET /entitlements, scoped to this Organization.
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - { name: application_id, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { $ref: "#/components/schemas/EntitlementStatus" } }
        - { name: order_id, in: query, schema: { type: string } }
        - { name: ends_before, in: query, schema: { type: string, format: date-time } }
        - { name: include_inactive, in: query, schema: { type: boolean } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of Entitlements
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EntitlementPage" }
    post:
      operationId: organizations.grantEntitlement
      tags: [Organizations]
      summary: Grant an app to the whole Organization
      description: "Requires entitlements.manage (platform, or the Application's own confined key) or the Application's owner. Never the org's own admin."
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [application_id]
              properties:
                application_id: { type: string }
                order_id: { type: [string, "null"], maxLength: 255 }
                starts_at: { type: string, format: date-time }
                ends_at: { type: [string, "null"], format: date-time }
                member_scope: { $ref: "#/components/schemas/EntitlementMemberScope" }
                member_overrides: { type: array, maxItems: 1000, items: { type: string } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "201":
          description: Org-wide Entitlement created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Entitlement" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  # ───────────────────────────── Tenancy ─────────────────────────────

  /tenants:
    get:
      operationId: tenants.list
      tags: [Tenancy]
      summary: List Tenants (your own; all with tenants.manage)
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - { name: tier, in: query, schema: { $ref: "#/components/schemas/TenantTier" } }
        - { name: status, in: query, schema: { $ref: "#/components/schemas/TenantStatus" } }
        - { name: owner_user_id, in: query, schema: { type: string } }
        - { name: owner_organization_id, in: query, schema: { type: string } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of Tenants
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TenantPage" }

  /tenants/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: tenants.get
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Tenancy]
      summary: Fetch one Tenant
      x-substratal-phase: phase2
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A Tenant
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Tenant" }
        "404": { $ref: "#/components/responses/NotFound" }

  /tenants/{id}/tier-change-requests:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: tenants.listTierChangeRequests
      tags: [Tenancy]
      summary: List tier-change requests for a Tenant
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of tier-change requests
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TenantTierChangeRequestPage" }
    post:
      operationId: tenants.requestTierChange
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Tenancy]
      summary: Open a tier-change request on a customer's behalf (tenants.manage)
      x-substratal-phase: phase2
      x-substratal-destructive: always
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [requested_tier, reason]
              properties:
                requested_tier: { type: string, enum: [isolated, dedicated_region] }
                requested_region: { $ref: "#/components/schemas/Region" }
                reason: { type: string, maxLength: 2000 }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "201":
          description: Tier-change request created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TenantTierChangeRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /tenants/{id}/tier-change-requests/{requestId}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: requestId, in: path, required: true, schema: { type: string } }
    get:
      operationId: tenants.getTierChangeRequest
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Tenancy]
      summary: Fetch one tier-change request
      x-substratal-phase: phase2
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TenantTierChangeRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: tenants.updateTierChangeRequest
      tags: [Tenancy]
      summary: Schedule, start, complete, or cancel a tier-change request (tenants.manage)
      x-substratal-phase: phase2
      x-substratal-destructive: always
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [scheduled, in_progress, completed, cancelled] }
                scheduled_for: { type: string, format: date-time }
                notes: { type: string, maxLength: 2000 }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Updated request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TenantTierChangeRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }

  # ───────────────────────────── Billing ─────────────────────────────

  /billing/prices:
    get:
      operationId: billing.listPrices
      tags: [Billing]
      summary: Standard's prices, monthly and yearly (User token)
      description: >-
        Any signed-in User. Each price is in cents per user per interval, the amount Checkout
        charges. 503 billing_unavailable when billing isn't configured or the prices can't be read.
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      responses:
        "200":
          description: Standard's prices
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/PlanPrice" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /subscriptions:
    get:
      operationId: billing.listSubscriptions
      tags: [Billing]
      summary: List every subscriber's subscription (billing.manage)
      description: >-
        Requires billing.manage. Every User's and Organization's subscription in the caller's mode,
        most recently updated first, with the subscriber's email or name for display. Revenue and
        invoices stay with the payment processor.
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Include"
        - name: status
          in: query
          description: "Subscriptions in any of these statuses. Repeatable."
          style: form
          explode: true
          schema: { type: array, items: { $ref: "#/components/schemas/SubscriptionStatus" }, maxItems: 8 }
        - { name: subscriber_type, in: query, schema: { type: string, enum: [user, organization] } }
        - { name: cancel_at_period_end, in: query, schema: { type: boolean } }
        - { name: current_period_end_before, in: query, description: "Subscriptions whose current period ends before this time.", schema: { type: string, format: date-time } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of subscriptions
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SubscriptionPage" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /users/{id}/subscription:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    get:
      operationId: billing.getUserSubscription
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Billing]
      summary: A User's plan, what covers it, and their own subscription (self or billing.manage)
      x-substratal-phase: phase2
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The User's plan and individual subscription
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserSubscription" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /users/me/billing/checkout-sessions:
    post:
      operationId: billing.createUserCheckoutSession
      tags: [Billing]
      summary: Start a Checkout to subscribe yourself to Standard (User token)
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CheckoutSessionCreate" }
      responses:
        "201":
          description: Checkout URL
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RedirectSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /users/me/billing/checkout-sessions/{session_id}/confirm:
    parameters:
      - { name: session_id, in: path, required: true, schema: { type: string }, description: "The Checkout Session id from success_url's session_id parameter." }
    post:
      operationId: billing.confirmUserCheckoutSession
      tags: [Billing]
      summary: Sync your subscription from a returning Checkout, without waiting for the webhook (User token)
      description: >-
        Call it when the person lands on success_url. It reads the Checkout Session and, if it's
        complete, applies the subscription at once (the same sync the billing webhook runs), then
        returns what GET /users/me/subscription would. An open or expired session changes nothing.
        Idempotent. 404 checkout_session_not_found if the session isn't this User's.
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      responses:
        "200":
          description: The User's plan and individual subscription, after the sync
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserSubscription" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /users/me/billing/portal-sessions:
    post:
      operationId: billing.createUserPortalSession
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Billing]
      summary: Open the billing portal for your own subscription (User token)
      x-substratal-phase: phase2
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PortalSessionCreate" }
      responses:
        "201":
          description: Portal URL
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RedirectSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "409": { $ref: "#/components/responses/Conflict" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /organizations/{id}/subscription:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: billing.getOrganizationSubscription
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Billing]
      summary: An Organization's plan and Standard subscription (org admin or billing.manage)
      x-substratal-phase: phase2
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The Organization's plan and subscription
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationSubscription" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: billing.updateOrganizationSubscription
      tags: [Billing]
      summary: Choose which members the Organization's subscription covers (org admin)
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                member_scope: { $ref: "#/components/schemas/EntitlementMemberScope" }
                member_overrides: { type: [array, "null"], maxItems: 1000, items: { type: string } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Updated
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationSubscription" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /organizations/{id}/billing/checkout-sessions:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: billing.createOrganizationCheckoutSession
      tags: [Billing]
      summary: Start a Checkout to subscribe the Organization to Standard (org admin, User token)
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/CheckoutSessionCreate"
                - type: object
                  properties:
                    member_scope: { $ref: "#/components/schemas/EntitlementMemberScope" }
                    member_overrides: { type: array, maxItems: 1000, items: { type: string } }
      responses:
        "201":
          description: Checkout URL
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RedirectSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /organizations/{id}/billing/checkout-sessions/{session_id}/confirm:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: session_id, in: path, required: true, schema: { type: string }, description: "The Checkout Session id from success_url's session_id parameter." }
    post:
      operationId: billing.confirmOrganizationCheckoutSession
      tags: [Billing]
      summary: Sync the Organization's subscription from a returning Checkout (org admin, User token)
      description: >-
        The Organization form of billing.confirmUserCheckoutSession: returns what
        GET /organizations/{id}/subscription would, after the sync.
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      responses:
        "200":
          description: The Organization's plan and subscription, after the sync
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationSubscription" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /organizations/{id}/billing/portal-sessions:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: billing.createOrganizationPortalSession
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Billing]
      summary: Open the billing portal for the Organization's subscription (org admin or billing.manage, User token)
      x-substratal-phase: phase2
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PortalSessionCreate" }
      responses:
        "201":
          description: Portal URL
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RedirectSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  # ───────────────────────────── Audit ─────────────────────────────

  /audit-events:
    get:
      operationId: audit.list
      tags: [Audit]
      summary: Query the audit log (hot window only)
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Include"
        - { name: target_user_id, in: query, schema: { type: string } }
        - { name: target_type, in: query, schema: { $ref: "#/components/schemas/AuditTargetType" } }
        - { name: target_id, in: query, schema: { type: string } }
        - { name: actor_id, in: query, schema: { type: string } }
        - { name: actor_type, in: query, schema: { type: string, enum: [user, api_key, system] } }
        - name: action
          in: query
          style: form
          explode: true
          schema: { type: array, items: { $ref: "#/components/schemas/AuditAction" } }
        - { name: application_id, in: query, schema: { type: string } }
        - { name: organization_id, in: query, schema: { type: string } }
        - { name: tenant_id, in: query, schema: { type: string } }
        - name: request_id
          in: query
          description: "Events written by one request: the X-Request-Id it sent or was given. Exact match."
          schema: { type: string, maxLength: 128 }
        - { name: since, in: query, schema: { type: string, format: date-time } }
        - { name: until, in: query, schema: { type: string, format: date-time } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of Audit Events
          headers:
            Audit-Hot-Window-Start:
              description: Earliest timestamp available to this caller's scope
              schema: { type: string, format: date-time }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuditEventPage" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /audit-events/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: audit.get
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Audit]
      summary: Fetch one Audit Event
      x-substratal-phase: mvp
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The event
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuditEvent" }
        "404": { $ref: "#/components/responses/NotFound" }

  /audit-exports:
    post:
      operationId: audit.createExport
      tags: [Audit]
      summary: Export Audit Events to a file (asynchronous)
      description: >-
        Exports every Audit Event matching the same filters as GET /audit-events, in the caller's
        scope there, as NDJSON or CSV. Hot window only. Returns 202 at once; a job writes the file,
        usually within a minute. Poll GET /audit-exports/{id} until status is ready, then download
        from download_url. Exports are kept 7 days. Writes audit.exported when the file is ready.
      x-substratal-phase: phase3
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AuditExportRequest" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "202":
          description: Export queued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuditExport" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /audit-exports/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: audit.getExport
      tags: [Audit]
      summary: Fetch an export's status, and its download link once ready
      description: "Visible to whoever requested it, and to audit.view. download_url is freshly signed on every call and valid for 15 minutes."
      x-substratal-phase: phase3
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The export
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuditExport" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───────────────────────────── Usage & stats ─────────────────────────────

  /usage:
    get:
      operationId: usage.get
      tags: [Usage & stats]
      summary: Request counts, errors, and latency per day, week, or month
      description: >-
        From a daily rollup of the request log, refreshed hourly: the current UTC day covers up to
        the last complete hour. Buckets are UTC; week is ISO (Monday start); period is the bucket's
        first day, so the buckets at either end of the range may be partial. Rows are in the
        caller's mode. Platform operations.view sees every row. An app-confined API Key sees only
        its own Application's rows, and an Application's owner sees only that Application's rows by
        passing application_id; both are forced. Anyone else gets 403.
      x-substratal-phase: phase3
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - name: since
          in: query
          description: "First day included (UTC). Default: 30 days before until."
          schema: { type: string, format: date }
        - name: until
          in: query
          description: "First day not included (UTC). Default: tomorrow, so today is included. At most 13 months after since."
          schema: { type: string, format: date }
        - name: granularity
          in: query
          schema: { type: string, enum: [day, week, month], default: day }
        - name: group_by
          in: query
          description: "Split rows by these dimensions. Repeat for several. Without it, one row per period."
          style: form
          explode: true
          schema: { type: array, items: { type: string, enum: [application, api_key, operation] } }
        - { name: application_id, in: query, schema: { type: string } }
        - { name: api_key_id, in: query, schema: { type: string } }
        - { name: operation, in: query, description: "An operationId, such as users.list.", schema: { type: string } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The usage report
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UsageReport" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /stats:
    get:
      operationId: stats.get
      tags: [Usage & stats]
      summary: Current counts for the console's home screen, in one call
      description: >-
        Live counts, grouped by status, computed at most once a minute per server instance
        (generated_at says when). Every known status is present, with 0 when nothing has it.
        Counts are in the caller's mode. Platform-wide with operations.view or users.list. With
        tenant_id, the counts are that Tenant's only, and its owner may call it too; users,
        subscriptions, and organizations are then null, since they don't belong to a Tenant.
      x-substratal-phase: phase3
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - { name: tenant_id, in: query, schema: { type: string } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The counts
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Stats" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───────────────────────────── Webhooks ─────────────────────────────

  /webhooks:
    get:
      operationId: webhooks.list
      tags: [Webhooks]
      summary: List subscriptions visible to the caller
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Include"
        - { name: application_id, in: query, schema: { type: string } }
        - name: status
          in: query
          description: "Subscriptions in any of these statuses. Repeatable."
          style: form
          explode: true
          schema: { type: array, items: { type: string, enum: [healthy, unhealthy, disabled] }, maxItems: 3 }
        - name: scope
          in: query
          description: >-
            "platform" returns only platform-scoped subscriptions. Callers without webhooks.manage see none, so
            they get an empty page. Can't be combined with application_id (400 invalid_request).
          schema: { type: string, enum: [platform] }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of Webhook subscriptions
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookPage" }
    post:
      operationId: webhooks.create
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Webhooks]
      summary: Subscribe a URL to event types
      x-substratal-phase: phase2
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  minItems: 1
                  items:
                    oneOf:
                      - $ref: "#/components/schemas/WebhookEvent"
                      - { type: string, const: "*" }
                description: { type: string, maxLength: 255 }
                scope: { type: string, description: "An application_id, or 'platform' (webhooks.manage only). Required for an owner's User token or a webhooks.manage caller; optional for an app key, where it defaults to (and must equal) the key's own Application." }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "201":
          description: Webhook created — includes the secret once
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookWithSecret" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /webhooks/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: webhooks.get
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Webhooks]
      summary: Fetch one subscription
      x-substratal-phase: phase2
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The subscription
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: webhooks.update
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Webhooks]
      summary: Change url, events, description, api_version, or re-enable
      x-substratal-phase: phase2
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
                events: { type: array, items: { type: string } }
                description: { type: string, maxLength: 255 }
                api_version: { type: string }
                status: { type: string, enum: [healthy] }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Updated subscription
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      operationId: webhooks.delete
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Webhooks]
      summary: Unsubscribe
      x-substratal-phase: phase2
      x-substratal-destructive: always
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "204": { description: Unsubscribed }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhooks/{id}/rotate-secret:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: webhooks.rotateSecret
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Webhooks]
      summary: Issue a new signing secret (24-hour overlap)
      x-substratal-phase: phase2
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: New secret
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  signing_secret: { type: string }
                  previous_secret_expires_at: { type: string, format: date-time }

  /webhooks/{id}/test:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: webhooks.sendTest
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Webhooks]
      summary: Send a webhook.test event now
      x-substratal-phase: phase2
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "202":
          description: Enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  delivery_id: { type: string }

  /webhooks/{id}/deliveries:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: webhooks.listDeliveries
      tags: [Webhooks]
      summary: Recent delivery attempts (30-day log)
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Include"
        - { name: event_id, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [pending, succeeded, failed] } }
        - { name: since, in: query, schema: { type: string, format: date-time } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of deliveries
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PageEnvelope"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/WebhookDelivery" } }

  /webhook-deliveries:
    get:
      operationId: webhooks.listAllDeliveries
      tags: [Webhooks]
      summary: Delivery attempts across every subscription visible to the caller (30-day log)
      description: >-
        Scoped like GET /webhooks: webhooks.manage sees every subscription's deliveries, an Application's
        owner and its app keys see only that Application's. A caller with no visible subscriptions gets an
        empty page. Newest first.
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Include"
        - { name: status, in: query, schema: { type: string, enum: [pending, succeeded, failed] } }
        - { name: since, in: query, description: "Deliveries created at or after this time.", schema: { type: string, format: date-time } }
        - { name: until, in: query, description: "Deliveries created before this time.", schema: { type: string, format: date-time } }
        - { name: application_id, in: query, schema: { type: string } }
        - { name: subscription_id, in: query, schema: { type: string } }
        - { name: event_type, in: query, schema: { $ref: "#/components/schemas/WebhookEvent" } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of deliveries
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookDeliveryPage" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /webhooks/{id}/deliveries/{deliveryId}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: deliveryId, in: path, required: true, schema: { type: string } }
    get:
      operationId: webhooks.getDelivery
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Webhooks]
      summary: Fetch one delivery attempt (30-day log)
      description: >-
        Visible to whoever can read GET /webhooks/{id}/deliveries. 404 delivery_not_found when the
        delivery isn't on that subscription or is older than the 30-day log.
      x-substratal-phase: phase2
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The delivery
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookDelivery" }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhooks/{id}/deliveries/{deliveryId}/redeliver:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: deliveryId, in: path, required: true, schema: { type: string } }
    post:
      operationId: webhooks.redeliver
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Webhooks]
      summary: Re-send one past event (same event id)
      x-substratal-phase: phase2
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "202": { description: Enqueued }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───────────────────────────── Notifications ─────────────────────────────

  /push-credentials:
    get:
      operationId: pushCredentials.listAll
      tags: [Notifications]
      summary: List push credentials across Applications (applications.manage, or an owner's own)
      description: >-
        applications.manage sees every Application's credentials. A User without it sees only the
        credentials of Applications they own (owner_user_id, or an active org_admin of the owning
        Organization); owning none is an empty list. Any other API Key, including an app key, gets 403
        and uses GET /applications/{id}/push-credentials instead. Never returns key material. Newest first.
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Include"
        - { name: status, in: query, schema: { type: string, enum: [active, failed, disabled] } }
        - { name: provider, in: query, schema: { $ref: "#/components/schemas/PushProvider" } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of push credentials
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PushCredentialPage" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /applications/{id}/push-credentials:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: pushCredentials.list
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Notifications]
      summary: List the push providers an Application has configured (the owner, the app's own key, or applications.manage)
      description: "Never returns key material. At most one credential per provider, so the list isn't paginated."
      x-substratal-phase: phase2
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Every configured provider
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PushCredentialList" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      operationId: pushCredentials.create
      tags: [Notifications]
      summary: Configure one push provider (the owner or applications.manage, User token only)
      description: "private_key (apns) and service_account (fcm) are write-only and stored KMS-encrypted. For web_push the platform generates the VAPID key pair and returns its public half. An API Key gets 400 user_token_required. A second credential for the same provider is 409 push_credential_exists; material that doesn't parse is 422 invalid_push_credential."
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PushCredentialCreate" }
      responses:
        "201":
          description: Credential configured (never echoes the secret)
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PushCredential" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /applications/{id}/push-credentials/{provider}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - name: provider
        in: path
        required: true
        schema: { $ref: "#/components/schemas/PushProvider" }
    get:
      operationId: pushCredentials.get
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Notifications]
      summary: Fetch one provider's configuration
      x-substratal-phase: phase2
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The credential
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PushCredential" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: pushCredentials.update
      tags: [Notifications]
      summary: Replace key material, change non-secret fields, or re-enable a failed credential (User token only)
      description: "Writable fields are the provider's own create fields, plus status (only failed → active). Any change to key material also returns a failed credential to active. The VAPID key pair can't be rotated in place: delete and recreate the web_push credential."
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PushCredentialUpdate" }
      responses:
        "200":
          description: The updated credential
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PushCredential" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      operationId: pushCredentials.delete
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Notifications]
      summary: Remove a push provider (User token only)
      description: "Pending deliveries to that provider are skipped. Removing web_push deactivates every web_push device of the Application (inactive_reason credential_removed), because browser subscriptions are bound to the VAPID key."
      x-substratal-phase: phase2
      x-substratal-destructive: always
      responses:
        "204": { description: Removed }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /users/{id}/devices:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    get:
      operationId: devices.list
      tags: [Notifications]
      summary: List a User's push devices (self, the app's own key, or users.list)
      description: "Only active devices unless status or include_inactive=true is passed. An app key sees only its own Application's devices."
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - { name: application_id, in: query, schema: { type: string } }
        - { name: provider, in: query, schema: { $ref: "#/components/schemas/PushProvider" } }
        - { name: status, in: query, schema: { $ref: "#/components/schemas/DeviceStatus" } }
        - { name: include_inactive, in: query, schema: { type: boolean, default: false } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of Devices
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DevicePage" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      operationId: devices.register
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Notifications]
      summary: Register a push device, or refresh an existing registration (self or the app's own key)
      description: "An idempotent upsert keyed on (application_id, provider, token). 201 for a new Device, 200 for a refresh of the caller's own. A token registered to a different User is moved: the old Device is deleted and a new one created (201). The User must have active access to application_id (403 entitlement_required). A User token ties the device to its session; an app key's registration has session_id null. At most 20 active devices per User per Application: a 21st deactivates the oldest (inactive_reason limit)."
      x-substratal-phase: phase2
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeviceRegistration" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Existing registration refreshed (and reactivated if it was inactive)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Device" }
        "201":
          description: Device registered
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Device" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /users/{id}/devices/{deviceId}:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
      - { name: deviceId, in: path, required: true, schema: { type: string } }
    get:
      operationId: devices.get
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Notifications]
      summary: Fetch one device
      x-substratal-phase: phase2
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The Device
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Device" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: devices.update
      tags: [Notifications]
      summary: Update a device's name, app version, environment, or rotated token (self or the app's own key)
      description: "Writing a token reactivates an inactive device. If the new token is already another Device of the same User and Application, that other registration is deleted."
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeviceUpdate" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The updated Device
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Device" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      operationId: devices.delete
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Notifications]
      summary: Unregister a device (self, the app's own key, or users.manage)
      description: "Hard delete. Pending deliveries to it are dropped. A users.manage delete of someone else's device writes the device.deleted Audit Event."
      x-substratal-phase: phase2
      x-substratal-destructive: always
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "204": { description: Unregistered }
        "404": { $ref: "#/components/responses/NotFound" }

  /users/{id}/devices/{deviceId}/test:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
      - { name: deviceId, in: path, required: true, schema: { type: string } }
    post:
      operationId: devices.sendTest
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Notifications]
      summary: Send a test push to one device and wait for the provider's answer (self or the app's own key)
      description: "Synchronous, up to 10 seconds. Ignores the push preference, works on inactive devices, isn't stored in the inbox, and is limited to 10 per device per hour. 409 push_not_configured if the Application has no credential for the device's provider."
      x-substratal-phase: phase2
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string, minLength: 1, maxLength: 120 }
                body: { type: [string, "null"], maxLength: 1000 }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The provider's answer
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeviceTestResult" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /users/{id}/notifications:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    get:
      operationId: notifications.list
      tags: [Notifications]
      summary: List a User's notification inbox (self, the app's own key, or users.list)
      description: "Newest first. Archived notifications are excluded unless state=archived or include_inactive=true. An app key sees only its own Application's notifications, never platform alerts."
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - { name: state, in: query, schema: { $ref: "#/components/schemas/NotificationState" } }
        - { name: application_id, in: query, schema: { type: string } }
        - { name: source, in: query, schema: { $ref: "#/components/schemas/NotificationSource" } }
        - { name: category, in: query, schema: { type: string } }
        - { name: priority, in: query, schema: { $ref: "#/components/schemas/NotificationPriority" } }
        - { name: created_after, in: query, schema: { type: string, format: date-time } }
        - { name: created_before, in: query, schema: { type: string, format: date-time } }
        - { name: include_inactive, in: query, schema: { type: boolean, default: false } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of Notifications (without deliveries)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NotificationPage" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      operationId: notifications.send
      tags: [Notifications]
      summary: Send one notification to one User (the app's own key with notifications.send, or platform notifications.send)
      description: "Always creates the notification in the User's inbox; push.status says what happened to the push. The recipient must have active access to the Application (403 entitlement_required). The whole push payload must fit in 4 KB (422 notification_too_large). Categories under security., account., and billing. are reserved for platform alerts (422 reserved_category). Over 60 notifications per User per Application per hour is 429 rate_limited with details.scope recipient."
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NotificationCreate" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "201":
          description: Notification created; push queued or skipped
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Notification" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /users/{id}/notifications/unread-count:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    get:
      operationId: notifications.unreadCount
      tags: [Notifications]
      summary: Count unread notifications, in total and per Application (self or the app's own key)
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - { name: application_id, in: query, schema: { type: string } }
        - { name: source, in: query, schema: { $ref: "#/components/schemas/NotificationSource" } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Unread, unarchived counts
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NotificationUnreadCount" }
        "404": { $ref: "#/components/responses/NotFound" }

  /users/{id}/notifications/mark-read:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
    post:
      operationId: notifications.markRead
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Notifications]
      summary: Mark every unread notification matching the filters as read (self or the app's own key)
      description: "Every filter is optional; an empty body marks everything the caller can see. before limits it to notifications created at or before that time. An app key's application_id is forced to its own."
      x-substratal-phase: phase2
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                application_id: { type: string }
                source: { $ref: "#/components/schemas/NotificationSource" }
                category: { type: string }
                before: { type: string, format: date-time }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: How many were marked read
          content:
            application/json:
              schema:
                type: object
                required: [updated_count]
                properties:
                  updated_count: { type: integer, minimum: 0 }
        "404": { $ref: "#/components/responses/NotFound" }

  /users/{id}/notifications/{notificationId}:
    parameters:
      - $ref: "#/components/parameters/UserIdOrMe"
      - { name: notificationId, in: path, required: true, schema: { type: string } }
    get:
      operationId: notifications.get
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [Notifications]
      summary: Fetch one notification, with its per-device deliveries
      x-substratal-phase: phase2
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The Notification with deliveries (empty after 30 days)
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NotificationWithDeliveries" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: notifications.update
      tags: [Notifications]
      summary: Mark one notification read, unread, or archived (self or the app's own key)
      x-substratal-phase: phase2
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [state]
              properties:
                state: { $ref: "#/components/schemas/NotificationState" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The updated Notification
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Notification" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  # ───────────────────────────── API Keys ─────────────────────────────

  /api-keys:
    get:
      operationId: apiKeys.list
      tags: [API Keys]
      summary: List keys the caller may manage
      x-substratal-phase: mvp
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Include"
        - { name: scope, in: query, schema: { type: string } }
        - { name: mode, in: query, schema: { type: string, enum: [live, test] } }
        - { name: intended_use, in: query, schema: { type: string, enum: [service, agent] } }
        - { name: q, in: query, description: "Case-insensitive prefix match on name", schema: { type: string } }
        - { name: include_inactive, in: query, description: "Include revoked and expired keys", schema: { type: boolean, default: false } }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: A page of API Keys
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyPage" }
    post:
      operationId: apiKeys.create
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [API Keys]
      summary: Create a key (api_keys.manage, or the Application's owner for an app-scoped key)
      x-substratal-phase: mvp
      x-substratal-step-up: conditional
      x-substratal-step-up-when: "scope is platform"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ApiKeyWrite" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "201":
          description: Key created — includes the secret once
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyWithSecret" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /api-keys/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: apiKeys.get
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [API Keys]
      summary: Fetch one key (never the secret)
      x-substratal-phase: mvp
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The key
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKey" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: apiKeys.update
      x-substratal-step-up: conditional
      x-substratal-step-up-when: "rate_limit_per_minute is sent"
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [API Keys]
      summary: Change name, permissions, restrict_destructive, or rate_limit_per_minute (api_keys.manage only)
      x-substratal-phase: mvp
      x-substratal-destructive: conditional
      x-substratal-destructive-when: "restrict_destructive set to false"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 100 }
                permissions: { type: array, items: { type: string } }
                restrict_destructive: { type: boolean }
                rate_limit_per_minute:
                  type: [integer, "null"]
                  minimum: 1
                  maximum: 10000
                  description: Platform api_keys.manage only (403 forbidden otherwise). Null restores the platform default.
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Updated key
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKey" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      operationId: apiKeys.delete
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [API Keys]
      summary: Revoke permanently
      x-substratal-phase: mvp
      x-substratal-destructive: always
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "204": { description: Revoked }
        "404": { $ref: "#/components/responses/NotFound" }

  /api-keys/{id}/rotate:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: apiKeys.rotate
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      tags: [API Keys]
      summary: Issue a new secret, invalidating the old one immediately (no overlap)
      x-substratal-phase: mvp
      x-substratal-destructive: always
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: New secret issued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyWithSecret" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───────────────────────────── Platform ─────────────────────────────

  /platform/settings:
    get:
      operationId: platform.getSettings
      tags: [Platform]
      summary: Read the platform's runtime switches and client feature flags
      description: >-
        Authentication is optional. Without a Bearer token: signup_open and
        announcement, for live mode, or for test mode with test_mode=true.
        With one: the caller's own mode, plus client_flags. With
        operations.view, also read_only and updated_at, read fresh; everyone
        else may get a copy up to 30 seconds old. ETag is the row's version.
      security:
        - {}
        - bearerAuth: []
      x-substratal-phase: phase3
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - name: test_mode
          in: query
          required: false
          description: "Unauthenticated callers only: read test mode's settings. An authenticated caller always reads its own mode."
          schema: { type: boolean, default: false }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The settings visible to this caller
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PlatformSettings" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
    patch:
      operationId: platform.updateSettings
      tags: [Platform]
      summary: Change the platform's runtime switches and client feature flags (operations.manage)
      description: >-
        Changes the settings of the caller's mode. If-Match is required (400
        if_match_required without it). client_flags is merged one level deep,
        and a flag sent as null is removed. announcement is replaced, and null
        clears it. Writes platform.settings_updated with before and after.
        Other API instances pick the change up within 30 seconds. It stays
        callable while read_only is on, so the freeze can always be lifted.
      x-substratal-phase: phase3
      x-substratal-step-up: always
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - name: If-Match
          in: header
          required: true
          description: The ETag from a prior read. Missing is 400 if_match_required; a mismatch is 409 version_conflict.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PlatformSettingsWrite" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: The updated settings, as an operations.view caller sees them
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PlatformSettings" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        A platform access token (JWT, from /auth/login, /auth/signup,
        /auth/mfa/verify, /auth/token/refresh) or an API Key secret
        (satk_live_... / satk_test_...). See Conventions → Authentication.

  headers:
    ETag:
      description: Weak validator over the row's version, e.g. W/"7". Send it back as If-Match.
      schema: { type: string }

  parameters:
    ApiVersion:
      name: Substratal-Version
      in: header
      required: true
      description: >-
        The API version this request is written against (Conventions → Versioning).
        Required on every operation except GET /health and POST /auth/oauth/token;
        a missing value returns 400 api_version_required, an unknown one 400
        unsupported_api_version. Every versioned response echoes it.
      schema: { type: string, enum: ["1"] }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, default: 25, maximum: 100 }
    Cursor:
      name: cursor
      in: query
      schema: { type: string }
    Include:
      name: include
      in: query
      description: >-
        `total` adds `page.total` and `page.total_capped`: the number of rows
        matching the current filters across every page, counted up to 10,000
        (Conventions → Pagination). Omitted unless requested.
      schema: { type: string, enum: [total] }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: Client-generated, 1–255 characters; a UUID v4 is recommended. Remembered 24 hours per caller.
      schema: { type: string, minLength: 1, maxLength: 255 }
    IdempotencyKeyOptional:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string, minLength: 1, maxLength: 255 }
    IfMatch:
      name: If-Match
      in: header
      required: false
      description: The ETag from a prior read. A mismatch returns 409 version_conflict.
      schema: { type: string }
    UserIdOrMe:
      name: id
      in: path
      required: true
      description: "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys)."
      schema: { type: string }
    AppId:
      name: appId
      in: path
      required: true
      schema: { type: string }

  responses:
    BadRequest:
      description: "Malformed request (invalid_request, invalid_cursor, user_token_required, idempotency_key_required, api_version_required, unsupported_api_version, token_invalid)"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: "Missing or invalid credentials (unauthenticated, session_revoked, invalid_credentials, invalid_mfa_code, …)"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: "Authenticated but not permitted (forbidden, destructive_operation_restricted, entitlement_required, role_escalation_forbidden, mfa_enrollment_required, reauthentication_required, …)"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource does not exist or is not visible to the caller
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: "State conflict (version_conflict, idempotency_key_reused, entitlement_already_exists, entitlement_platform_managed, subscription_exists, user_protected, invalid_status_transition, …)"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unprocessable:
      description: "Semantically invalid (validation_failed with details.fields, settings_schema_violation, weak_password, …)"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    TooManyRequests:
      description: "rate_limited / too_many_attempts — see Retry-After"
      headers:
        Retry-After: { schema: { type: integer } }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ServiceUnavailable:
      description: "service_unavailable / billing_unavailable — see Retry-After"
      headers:
        Retry-After: { schema: { type: integer } }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string, example: entitlement_required }
            message: { type: string }
            details:
              type: object
              properties:
                fields:
                  type: array
                  items:
                    type: object
                    required: [field, code]
                    properties:
                      field: { type: string }
                      code: { type: string }
              additionalProperties: true

    BulkItemError:
      type: object
      description: The error object the single call would have returned, without the outer error envelope.
      required: [code, message]
      properties:
        code: { type: string, example: user_protected }
        message: { type: string }
        details: { type: object, additionalProperties: true }

    BulkResults:
      type: object
      description: One result per requested item, in request order (Conventions → Bulk operations).
      required: [results]
      properties:
        results:
          type: array
          items:
            type: object
            required: [id, status]
            properties:
              id: { type: string, description: The item's id, as sent }
              status: { type: integer, description: "The status the single call would have returned: 200 on success, otherwise the error's 4xx or 500" }
              error: { $ref: "#/components/schemas/BulkItemError" }

    BulkInviteResults:
      type: object
      description: One result per requested email, in request order (Conventions → Bulk operations).
      required: [results]
      properties:
        results:
          type: array
          items:
            type: object
            required: [email, status]
            properties:
              email: { type: string, description: The email, as sent }
              status: { type: integer, description: "The status POST /users would have returned: 201 on success, otherwise the error's 4xx or 500" }
              user_id: { type: string, description: The new User's id. Present only on 201. }
              error: { $ref: "#/components/schemas/BulkItemError" }

    OAuthError:
      type: object
      required: [error]
      properties:
        error: { type: string, enum: [invalid_request, invalid_grant, invalid_client, unsupported_grant_type, access_denied] }
        error_description: { type: string }

    PageInfo:
      type: object
      required: [next_cursor, has_more]
      properties:
        next_cursor: { type: [string, "null"] }
        has_more: { type: boolean }
        total:
          type: integer
          minimum: 0
          maximum: 10000
          description: >-
            Only with `include=total`. Rows matching the current filters across
            every page, ignoring `cursor`; 10000 when capped.
        total_capped:
          type: boolean
          description: Only with `include=total`. True when there are more than 10,000 matching rows.

    PageEnvelope:
      type: object
      required: [data, page]
      properties:
        page: { $ref: "#/components/schemas/PageInfo" }

    Actor:
      type: object
      required: [type, id]
      properties:
        type: { type: string, enum: [user, api_key, system] }
        id: { type: string }

    Region:
      type: string
      enum: [us-east-1, us-west-2, ca-central-1, eu-west-1, eu-central-1, ap-southeast-2]

    # ── Auth ──
    AuthSession:
      type: object
      required: [token_type, access_token, expires_in, refresh_token, session_id, user_id]
      properties:
        token_type: { type: string, enum: [Bearer] }
        access_token: { type: string }
        expires_in: { type: integer, example: 900 }
        refresh_token: { type: string }
        refresh_token_expires_in: { type: integer, example: 2592000 }
        session_id: { type: string }
        user_id: { type: string }
        email_verified: { type: boolean }

    Reauthentication:
      type: object
      required: [token_type, access_token, expires_in, mfa_verified_at]
      properties:
        token_type: { type: string, enum: [Bearer] }
        access_token: { type: string, description: "A new platform access token for the same session, carrying the updated mfa_verified_at claim." }
        expires_in: { type: integer, example: 900 }
        mfa_verified_at: { type: string, format: date-time, description: "When this session last proved a second factor: now." }

    PasskeyCreationOptions:
      type: object
      required: [publicKey, expires_in]
      properties:
        publicKey:
          type: object
          description: >-
            PublicKeyCredentialCreationOptions in their JSON form (binary
            values base64url), ready for
            PublicKeyCredential.parseCreationOptionsFromJSON().
          required: [rp, user, challenge]
          properties:
            rp: { type: object, properties: { id: { type: string, example: substratalapps.com }, name: { type: string, example: Substratal } } }
            user: { type: object, properties: { id: { type: string }, name: { type: string }, displayName: { type: string } } }
            challenge: { type: string }
            pubKeyCredParams: { type: array, items: { type: object } }
            timeout: { type: integer }
            excludeCredentials: { type: array, items: { type: object } }
            authenticatorSelection: { type: object }
            attestation: { type: string }
        expires_in: { type: integer, example: 300, description: "Seconds the challenge stays valid." }

    PasskeyRequestOptions:
      type: object
      required: [publicKey, expires_in]
      properties:
        publicKey:
          type: object
          description: >-
            PublicKeyCredentialRequestOptions in their JSON form, ready for
            PublicKeyCredential.parseRequestOptionsFromJSON().
          required: [challenge]
          properties:
            challenge: { type: string }
            timeout: { type: integer }
            rpId: { type: string, example: substratalapps.com }
            allowCredentials: { type: array, items: { type: object } }
            userVerification: { type: string }
        expires_in: { type: integer, example: 300 }

    PasskeyAttestation:
      type: object
      description: "The PublicKeyCredential from navigator.credentials.create(), as JSON (toJSON())."
      required: [id, rawId, type, response]
      properties:
        id: { type: string }
        rawId: { type: string }
        type: { type: string, enum: [public-key] }
        response:
          type: object
          required: [clientDataJSON, attestationObject]
          properties:
            clientDataJSON: { type: string }
            attestationObject: { type: string }
            transports: { type: array, items: { type: string } }
        authenticatorAttachment: { type: string }
        clientExtensionResults: { type: object }

    PasskeyAssertion:
      type: object
      description: "The PublicKeyCredential from navigator.credentials.get(), as JSON (toJSON())."
      required: [id, rawId, type, response]
      properties:
        id: { type: string }
        rawId: { type: string }
        type: { type: string, enum: [public-key] }
        response:
          type: object
          required: [clientDataJSON, authenticatorData, signature]
          properties:
            clientDataJSON: { type: string }
            authenticatorData: { type: string }
            signature: { type: string }
            userHandle: { type: [string, "null"] }
        authenticatorAttachment: { type: string }
        clientExtensionResults: { type: object }

    Passkey:
      type: object
      description: "A registered passkey. Never includes the credential id or public key."
      required: [id, name, transports, backup_eligible, backup_state, created_at, last_used_at]
      properties:
        id: { type: string, example: wac_01JAG8M2N3P4Q5R6S7T8U9V0W1 }
        name: { type: string, maxLength: 64 }
        aaguid: { type: [string, "null"], description: "The authenticator model (a UUID), when it reported one." }
        transports: { type: array, items: { type: string } }
        backup_eligible: { type: boolean, description: "A synced passkey (it can be backed up)." }
        backup_state: { type: boolean, description: "Currently backed up." }
        created_at: { type: string, format: date-time }
        last_used_at: { type: [string, "null"], format: date-time }

    PasskeyList:
      type: object
      required: [data]
      properties:
        data: { type: array, maxItems: 10, items: { $ref: "#/components/schemas/Passkey" } }

    PasskeyRegistration:
      type: object
      required: [passkey, mfa_enabled]
      properties:
        passkey: { $ref: "#/components/schemas/Passkey" }
        mfa_enabled: { type: boolean, const: true }
        recovery_codes:
          type: array
          items: { type: string }
          minItems: 10
          maxItems: 10
          description: "Only when this passkey is the User's first MFA method. Shown once."

    MfaChallenge:
      type: object
      required: [mfa_required, mfa_token, methods, expires_in]
      properties:
        mfa_required: { type: boolean, const: true }
        mfa_token: { type: string }
        methods: { type: array, items: { type: string, enum: [totp, passkey, recovery_code] }, description: "The second factors this User can complete the challenge with: totp when TOTP is on, passkey when they have a passkey, and always recovery_code." }
        expires_in: { type: integer, example: 300 }

    AppToken:
      type: object
      required: [token_type, app_token, expires_in, application_id]
      properties:
        token_type: { type: string, enum: [Bearer] }
        app_token: { type: string, description: "RS256 JWT; aud = application_id; 5-minute TTL. Claims: see AppTokenClaims." }
        expires_in: { type: integer, example: 300 }
        application_id: { type: string }

    AppTokenClaims:
      type: object
      description: Decoded payload of an app token, for Application implementers.
      properties:
        iss: { type: string, const: "https://api.substratalapps.com" }
        aud: { type: string }
        sub: { type: string }
        sid: { type: string }
        org_id: { type: [string, "null"] }
        email: { type: string }
        email_verified: { type: boolean }
        entitlement_status: { $ref: "#/components/schemas/ResolvedEntitlementStatus" }
        effective_permissions: { type: array, items: { type: string } }
        test_mode: { type: boolean }
        iat: { type: integer }
        exp: { type: integer }
        jti: { type: string }

    OAuthTokenRequest:
      type: object
      required: [grant_type, client_id]
      properties:
        grant_type: { type: string, enum: [authorization_code, refresh_token] }
        client_id: { type: string, description: The application_id }
        code: { type: string }
        code_verifier: { type: string }
        redirect_uri: { type: string }
        refresh_token: { type: string }

    Session:
      type: object
      properties:
        id: { type: string }
        created_at: { type: string, format: date-time }
        last_seen_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time, description: "The earlier of the idle expiry (30 days after the last refresh) and the absolute expiry (90 days after creation)." }
        ip_address: { type: string }
        user_agent: { type: string }
        amr: { type: array, items: { type: string } }
        current: { type: boolean }

    # ── Users ──
    UserStatus:
      type: string
      enum: [active, invited, suspended, deleted]

    User:
      type: object
      properties:
        id: { type: string }
        email: { type: string, format: email }
        email_verified: { type: boolean }
        pending_email: { type: [string, "null"], format: email }
        status: { $ref: "#/components/schemas/UserStatus" }
        mfa_enabled: { type: boolean, description: "TOTP confirmed, or at least one passkey (Auth → MFA)." }
        mfa_enrollment_required: { type: boolean, description: "Staff (holding a platform Role that grants a permission) without MFA, neither TOTP nor a passkey. Once staff MFA is enforced, their sessions can only enroll, read themselves, change password, and log out (Auth → Staff MFA)." }
        signup_application_id: { type: [string, "null"] }
        test_mode: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        last_login_at: { type: [string, "null"], format: date-time }
        expires_at: { type: [string, "null"], format: date-time, description: "When a test-mode User stops working (Auth → Test-mode Users → Lifetime). Always null for live Users." }
        plan: { $ref: "#/components/schemas/Plan" }
        plan_sources: { type: array, items: { $ref: "#/components/schemas/PlanSource" }, description: "What gives this User Standard: their own subscription, an Organization's, or both. Empty on Free." }

    UserPage:
      allOf:
        - $ref: "#/components/schemas/PageEnvelope"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/User" } }

    ErasureRequest:
      type: object
      properties:
        user_id: { type: string }
        status: { type: string, enum: [scheduled, cancelled, completed] }
        requested_at: { type: string, format: date-time }
        scheduled_for: { type: string, format: date-time }

    UserExport:
      type: object
      description: "Reads from the same tables the hard-delete cascade writes to — deliberately symmetric with it."
      properties:
        exported_at: { type: string, format: date-time }
        user: { $ref: "#/components/schemas/User" }
        profile: { $ref: "#/components/schemas/Profile" }
        settings: { $ref: "#/components/schemas/Settings" }
        identities:
          type: array
          description: "Never includes password_hash or mfa_secret."
          items:
            type: object
            properties:
              method: { type: string, enum: [password, sso] }
              mfa_enabled: { type: boolean }
              last_used_at: { type: [string, "null"], format: date-time }
        passkeys:
          type: array
          description: "Passkey metadata. Never includes the credential id or public key."
          items: { $ref: "#/components/schemas/Passkey" }
        sessions: { type: array, items: { $ref: "#/components/schemas/Session" } }
        organizations:
          type: array
          items:
            type: object
            properties:
              organization_id: { type: string }
              role: { $ref: "#/components/schemas/OrganizationMemberRole" }
              joined_at: { type: string, format: date-time }
        roles: { type: array, items: { $ref: "#/components/schemas/UserRoleAssignment" } }
        applications:
          type: array
          items:
            type: object
            properties:
              application_id: { type: string }
              entitlement: { $ref: "#/components/schemas/Entitlement" }
              app_profile: { $ref: "#/components/schemas/AppProfile" }
              app_settings:
                type: object
                properties:
                  overrides: { type: object }
        audit_events:
          type: array
          description: "This user's own trail as target_user_id (hot window)."
          items: { $ref: "#/components/schemas/AuditEvent" }
        devices:
          type: array
          description: "Push devices, active and inactive. Never includes the token."
          items: { $ref: "#/components/schemas/Device" }
        notifications:
          type: array
          description: "Every notification still retained (90 days), without deliveries."
          items: { $ref: "#/components/schemas/Notification" }

    # ── Profiles & Settings ──
    ProfileWrite:
      type: object
      properties:
        display_name: { type: string, minLength: 1, maxLength: 100 }
        avatar_url: { type: [string, "null"], format: uri, maxLength: 2048 }
        contact_email: { type: [string, "null"], format: email }
        contact_phone: { type: [string, "null"], pattern: "^\\+[1-9][0-9]{7,14}$" }

    Profile:
      allOf:
        - $ref: "#/components/schemas/ProfileWrite"
        - type: object
          properties:
            user_id: { type: string }
            locale: { type: string, readOnly: true, example: en-US, description: "Mirror of the global Settings locale; write it there." }
            updated_at: { type: string, format: date-time }

    ImageUploadRequest:
      type: object
      required: [content_type, content_length]
      properties:
        content_type: { type: string, enum: [image/png, image/jpeg, image/webp] }
        content_length: { type: integer, minimum: 1, description: "The file's size in bytes. At most 2097152 for an avatar, 1048576 for an icon." }

    ImageUpload:
      type: object
      required: [id, url, fields, content_type, max_bytes, expires_at]
      description: >-
        A presigned POST. Send multipart/form-data to url with every entry of fields, then the
        file as the last field, named file. The upload is accepted only with that content_type and
        at most max_bytes, until expires_at.
      properties:
        id: { type: string, description: "The upload id (upl_…) to complete." }
        url: { type: string, description: "Where to POST the form." }
        fields:
          type: object
          additionalProperties: { type: string }
          description: "Form fields to send unchanged, including key, Content-Type, policy, and signature."
        content_type: { type: string, enum: [image/png, image/jpeg, image/webp] }
        max_bytes: { type: integer }
        expires_at: { type: string, format: date-time }

    AppProfile:
      type: object
      properties:
        user_id: { type: string }
        application_id: { type: string }
        display_handle: { type: [string, "null"] }
        effective_display_name: { type: string, readOnly: true }
        custom: { type: object }
        updated_at: { type: [string, "null"], format: date-time }

    SettingsWrite:
      type: object
      properties:
        locale: { type: string }
        timezone: { type: string, example: America/Denver }
        theme: { type: string, enum: [light, dark, system] }
        notifications:
          type: object
          additionalProperties: { type: [boolean, "null"] }

    Settings:
      allOf:
        - $ref: "#/components/schemas/SettingsWrite"
        - type: object
          properties:
            user_id: { type: string }
            updated_at: { type: string, format: date-time }

    AppSettingsResolved:
      type: object
      properties:
        user_id: { type: string }
        application_id: { type: string }
        resolved: { type: object }
        sources:
          type: object
          additionalProperties: { type: string, enum: [app_override, global, app_default] }
        overrides: { type: object }
        stale_overrides: { type: array, items: { type: string } }
        updated_at: { type: [string, "null"], format: date-time }

    # ── Applications ──
    ApplicationVisibility:
      type: string
      enum: [public, invite_only, internal]

    ApplicationReviewStatus:
      type: string
      enum: [approved, pending_review, rejected, suspended, retired]

    ApplicationPermission:
      type: object
      required: [key]
      properties:
        key: { type: string, pattern: "^app\\.[a-z0-9_-]+\\.[a-z0-9_.]{1,64}$" }
        description: { type: string, maxLength: 255 }

    ApplicationAccessTier:
      type: string
      enum: [free, standard, restricted]
      description: >-
        Who gets in without a grant. free: every User. standard: Users covered by a Standard
        subscription; first_party (Substratal's own) Applications only. restricted: only the people
        the developer grants Entitlements to, from their own billing or none. A first-party
        Application defaults to standard and only applications.manage changes its tier. Any other
        Application defaults to restricted, and its owner chooses free or restricted while it's
        approved or pending_review (409 application_not_available otherwise, at most 10 changes a
        day). standard on one that isn't first-party is 422 access_tier_reserved.

    ApplicationSummary:
      type: object
      required: [id, slug, name, description, icon_url, visibility, access_tier, review_status, review_requested_at, owner_user_id, owner_organization_id, holder_count]
      properties:
        id: { type: string }
        slug: { type: string }
        name: { type: string }
        description: { type: [string, "null"] }
        icon_url: { type: [string, "null"] }
        visibility: { $ref: "#/components/schemas/ApplicationVisibility" }
        access_tier: { $ref: "#/components/schemas/ApplicationAccessTier" }
        review_status: { $ref: "#/components/schemas/ApplicationReviewStatus" }
        review_requested_at: { type: [string, "null"], format: date-time, readOnly: true, description: "When the Application last entered pending_review. See Application." }
        owner_user_id: { type: [string, "null"] }
        owner_organization_id: { type: [string, "null"] }
        holder_count: { type: integer, minimum: 0, description: "Active Entitlements for the Application; a User grant and an Organization grant count once each" }

    ApplicationSummaryPage:
      allOf:
        - $ref: "#/components/schemas/PageEnvelope"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/ApplicationSummary" } }

    ApplicationWrite:
      type: object
      properties:
        slug: { type: string, pattern: "^[a-z][a-z0-9-]{1,63}$", description: "Create only; immutable. 'platform' is reserved (rejected with validation_failed, code unknown_value). The Application's id is app_<slug>." }
        name: { type: string, minLength: 1, maxLength: 100 }
        description: { type: [string, "null"], maxLength: 2000 }
        icon_url: { type: [string, "null"], format: uri }
        launch_url: { type: string, format: uri }
        redirect_uris: { type: array, maxItems: 10, items: { type: string } }
        support_url: { type: [string, "null"], format: uri }
        email_from_name: { type: [string, "null"], maxLength: 50 }
        settings_schema: { type: object, description: "JSON Schema 2020-12 subset — see Domain Model → Settings → settings_schema rules" }
        permissions: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/ApplicationPermission" } }
        available_app_roles: { type: array, maxItems: 20, items: { type: string, pattern: "^[a-z][a-z0-9_]{1,40}$" } }
        default_app_role: { type: [string, "null"] }
        visibility: { $ref: "#/components/schemas/ApplicationVisibility" }
        access_tier: { $ref: "#/components/schemas/ApplicationAccessTier" }
        first_party: { type: boolean, default: false, description: "Substratal's own Application. Only applications.manage sets it, and only a first-party Application can be access_tier standard. Staff-created customer Applications (concierge onboarding) aren't first-party." }
        owner_user_id: { type: [string, "null"] }
        owner_organization_id: { type: [string, "null"] }
        review_status: { $ref: "#/components/schemas/ApplicationReviewStatus" }
        review_notes: { type: [string, "null"], maxLength: 2000 }
        receives_platform_notifications: { type: boolean, default: false, description: "When true, this Application's devices also receive pushes for platform alerts (security and account notices). A configuration field the owner may set." }

    Application:
      allOf:
        - $ref: "#/components/schemas/ApplicationWrite"
        - type: object
          properties:
            id: { type: string }
            tenant_id: { type: string, readOnly: true }
            settings_schema_stats:
              type: object
              readOnly: true
              properties:
                stale_override_counts: { type: object, additionalProperties: { type: integer } }
            self_registered: { type: boolean, readOnly: true, description: "true when a developer submitted it themselves, not staff. A self-registered Application must use the hosted sign-in flow (POST /auth/app-tokens returns 403 embedded_login_not_allowed), and only applications.manage can turn on receives_platform_notifications for it." }
            review_requested_at: { type: [string, "null"], format: date-time, readOnly: true, description: "When the Application last entered pending_review. See Application." }
            test_mode: { type: boolean }
            created_at: { type: string, format: date-time }
            updated_at: { type: string, format: date-time }

    # ── Entitlements ──
    EntitlementStatus:
      type: string
      enum: [active, disabled, expired, revoked]

    ResolvedEntitlementStatus:
      type: string
      enum: [active, scheduled, disabled, expired, revoked, none]
      description: "scheduled: the only live path is a grant whose starts_at is in the future." 

    EntitlementSource:
      type: string
      enum: [purchase, trial, admin_grant, org_seat, free, subscription]
      description: "free and subscription rows are platform-managed: granted and ended by the platform from the Application's access_tier and the holder's subscription. An admin may disable and re-enable one; nothing else about it can be changed."

    EntitlementMemberScope:
      type: string
      enum: [all_members, allowlist, denylist]

    EntitlementGrantedVia:
      type: object
      description: "Computed per member at read time; present only when reading a specific member's view of an org-wide grant."
      properties:
        organization_id: { type: string }
        member_decision: { type: string, enum: [included, excluded] }

    Entitlement:
      type: object
      properties:
        id: { type: string }
        user_id: { type: [string, "null"] }
        organization_id: { type: [string, "null"] }
        application_id: { type: string }
        status: { $ref: "#/components/schemas/EntitlementStatus" }
        source: { $ref: "#/components/schemas/EntitlementSource" }
        order_id: { type: [string, "null"], maxLength: 255, description: "Opaque developer-supplied billing reference" }
        subscription_id: { type: [string, "null"], readOnly: true, description: "The subscription a source: subscription row comes from." }
        starts_at: { type: string, format: date-time }
        ends_at: { type: [string, "null"], format: date-time }
        disabled_reason: { type: [string, "null"] }
        member_scope: { oneOf: [ { $ref: "#/components/schemas/EntitlementMemberScope" }, { type: "null" } ] }
        member_overrides: { type: [array, "null"], items: { type: string } }
        included_member_count: { type: integer, readOnly: true }
        granted_via: { $ref: "#/components/schemas/EntitlementGrantedVia" }
        test_mode: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    EntitlementPage:
      allOf:
        - $ref: "#/components/schemas/PageEnvelope"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/Entitlement" } }

    # ── Roles & Permissions ──
    Permission:
      type: object
      properties:
        key: { type: string }
        scope: { type: string }
        description: { type: string }

    PermissionPage:
      allOf:
        - $ref: "#/components/schemas/PageEnvelope"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/Permission" } }

    RoleCreate:
      type: object
      required: [name, scope, permissions]
      properties:
        name: { type: string, pattern: "^[a-z][a-z0-9_]{1,40}$" }
        scope: { type: string, description: "'platform' or an application_id" }
        description: { type: [string, "null"], maxLength: 2000 }
        permissions: { type: array, items: { type: string } }

    Role:
      allOf:
        - $ref: "#/components/schemas/RoleCreate"
        - type: object
          properties:
            id: { type: string, description: "role_platform_<name> or role_<slug>_<name>" }
            seed: { type: boolean }
            assignment_count: { type: integer }
            created_at: { type: string, format: date-time }
            updated_at: { type: string, format: date-time }

    RolePage:
      allOf:
        - $ref: "#/components/schemas/PageEnvelope"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/Role" } }

    UserRoleAssignment:
      type: object
      properties:
        user_id: { type: string }
        role_id: { type: string }
        application_id: { type: [string, "null"] }
        assigned_at: { type: [string, "null"], format: date-time }
        assigned_by: { oneOf: [ { $ref: "#/components/schemas/Actor" }, { type: "null" } ] }
        implicit: { type: boolean }
        mfa_enrollment_required: { type: boolean, description: "On POST /users/{id}/roles/{roleId} only: the User is now staff but has no MFA (neither TOTP nor a passkey), so their platform Roles take effect once they enroll (Auth → Staff MFA)." }

    UserRoleAssignmentPage:
      allOf:
        - $ref: "#/components/schemas/PageEnvelope"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/UserRoleAssignment" } }

    EffectivePermissions:
      type: object
      required: [user_id, application_id, allowed, user_status, entitlement_status, effective_permissions]
      properties:
        user_id: { type: string }
        application_id: { type: string }
        allowed: { type: boolean }
        user_status: { $ref: "#/components/schemas/UserStatus" }
        entitlement_status: { $ref: "#/components/schemas/ResolvedEntitlementStatus" }
        access_paths:
          type: array
          items:
            type: object
            properties:
              source: { $ref: "#/components/schemas/EntitlementSource" }
              entitlement_id: { type: string }
              status: { $ref: "#/components/schemas/EntitlementStatus" }
              organization_id: { type: [string, "null"] }
              member_decision: { type: [string, "null"], enum: [included, excluded, null] }
        roles: { type: array, items: { type: string } }
        effective_permissions: { type: array, items: { type: string } }
        computed_at: { type: string, format: date-time }

    # ── Organizations ──
    Organization:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        status: { type: string, enum: [active, suspended] }
        member_count: { type: integer, description: "Active members only" }
        my_role: { $ref: "#/components/schemas/OrganizationMemberRole" }
        my_membership_status: { $ref: "#/components/schemas/OrganizationMembershipStatus" }
        created_by: { type: string }
        test_mode: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    OrganizationPage:
      allOf:
        - $ref: "#/components/schemas/PageEnvelope"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/Organization" } }

    OrganizationMemberRole:
      type: string
      enum: [org_admin, member]

    OrganizationMember:
      type: object
      properties:
        user_id: { type: string }
        email: { type: string, format: email }
        display_name: { type: [string, "null"], description: "null while the membership is pending" }
        membership_status: { $ref: "#/components/schemas/OrganizationMembershipStatus" }
        role: { $ref: "#/components/schemas/OrganizationMemberRole" }
        invited_at: { type: string, format: date-time }
        joined_at: { type: [string, "null"], format: date-time }

    OrganizationMembershipStatus:
      type: string
      enum: [pending, active]

    OrganizationMemberPage:
      allOf:
        - $ref: "#/components/schemas/PageEnvelope"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/OrganizationMember" } }

    # ── Tenancy & Billing ──
    TenantOwnerType:
      type: string
      enum: [user, organization]

    TenantTier:
      type: string
      enum: [shared, isolated, dedicated_region]

    TenantStatus:
      type: string
      enum: [active, migrating, suspended]

    SubscriptionStatus:
      type: string
      enum: [active, trialing, past_due, canceled, unpaid, incomplete, incomplete_expired, paused]
      description: "Mirrors the payment processor. active, trialing, and past_due cover their users with Standard; the rest don't."

    Tenant:
      type: object
      properties:
        id: { type: string }
        owner_type: { $ref: "#/components/schemas/TenantOwnerType" }
        owner_user_id: { type: [string, "null"] }
        owner_organization_id: { type: [string, "null"] }
        tier: { $ref: "#/components/schemas/TenantTier" }
        region: { $ref: "#/components/schemas/Region" }
        status: { $ref: "#/components/schemas/TenantStatus" }
        application_count: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    TenantPage:
      allOf:
        - $ref: "#/components/schemas/PageEnvelope"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/Tenant" } }

    TenantTierChangeRequestStatus:
      type: string
      enum: [pending, scheduled, in_progress, completed, cancelled]

    TenantTierChangeRequest:
      type: object
      properties:
        id: { type: string }
        tenant_id: { type: string }
        from_tier: { $ref: "#/components/schemas/TenantTier" }
        from_region: { $ref: "#/components/schemas/Region" }
        requested_tier: { $ref: "#/components/schemas/TenantTier" }
        requested_region: { oneOf: [ { $ref: "#/components/schemas/Region" }, { type: "null" } ] }
        status: { $ref: "#/components/schemas/TenantTierChangeRequestStatus" }
        reason: { type: string }
        notes: { type: [string, "null"] }
        scheduled_for: { type: [string, "null"], format: date-time }
        requested_by: { type: string }
        created_at: { type: string, format: date-time }
        started_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }

    TenantTierChangeRequestPage:
      allOf:
        - $ref: "#/components/schemas/PageEnvelope"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/TenantTierChangeRequest" } }

    Plan:
      type: string
      enum: [free, standard]
      description: "Business and enterprise plans come later."

    PlanSource:
      type: object
      required: [type, id]
      properties:
        type: { type: string, enum: [user, organization] }
        id: { type: string, description: "The User (an individual subscription) or the Organization whose subscription covers this User." }

    Subscription:
      type: object
      properties:
        id: { type: string, description: "sbs_ prefix" }
        subscriber_type: { type: string, enum: [user, organization] }
        user_id: { type: [string, "null"] }
        organization_id: { type: [string, "null"] }
        plan: { type: string, enum: [standard] }
        status: { $ref: "#/components/schemas/SubscriptionStatus" }
        current_period_start: { type: [string, "null"], format: date-time }
        current_period_end: { type: [string, "null"], format: date-time }
        cancel_at_period_end: { type: boolean }
        member_scope: { oneOf: [ { $ref: "#/components/schemas/EntitlementMemberScope" }, { type: "null" } ], description: "Organization subscriptions only." }
        member_overrides: { type: [array, "null"], items: { type: string } }
        quantity:
          type: object
          description: "Users billed. Always 1 for an individual subscription; for an Organization, covered members."
          properties:
            current: { type: integer }
            last_synced: { type: [integer, "null"] }
            last_synced_at: { type: [string, "null"], format: date-time }
        interval: { $ref: "#/components/schemas/BillingInterval" }
        unit_amount: { type: integer, description: "The subscribed price, in cents per user per interval." }
        currency: { type: string, const: usd }
        test_mode: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    SubscriptionListItem:
      allOf:
        - $ref: "#/components/schemas/Subscription"
        - type: object
          properties:
            user_email: { type: [string, "null"], format: email, description: "Individual subscriptions: the User's email, for display." }
            organization_name: { type: [string, "null"], description: "Organization subscriptions: the Organization's name, for display." }

    SubscriptionPage:
      allOf:
        - $ref: "#/components/schemas/PageEnvelope"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/SubscriptionListItem" } }

    UserSubscription:
      type: object
      properties:
        user_id: { type: string }
        plan: { $ref: "#/components/schemas/Plan" }
        plan_sources: { type: array, items: { $ref: "#/components/schemas/PlanSource" } }
        subscription: { oneOf: [ { $ref: "#/components/schemas/Subscription" }, { type: "null" } ], description: "The User's own individual subscription, if they've ever started one." }

    OrganizationSubscription:
      type: object
      properties:
        organization_id: { type: string }
        plan: { $ref: "#/components/schemas/Plan" }
        subscription: { oneOf: [ { $ref: "#/components/schemas/Subscription" }, { type: "null" } ] }

    BillingInterval: { type: string, enum: [month, year] }

    PlanPrice:
      type: object
      required: [plan, interval, unit_amount, currency]
      properties:
        plan: { type: string, enum: [standard] }
        interval: { $ref: "#/components/schemas/BillingInterval" }
        unit_amount: { type: integer, description: "Cents per user per interval." }
        currency: { type: string, const: usd }

    CheckoutSessionCreate:
      type: object
      required: [plan, success_url, cancel_url]
      properties:
        plan: { type: string, enum: [standard, business, enterprise], description: "Only standard is self-service; business and enterprise return 422 plan_requires_sales." }
        interval: { $ref: "#/components/schemas/BillingInterval", description: "How often Standard is billed. Default month." }
        success_url: { type: string, format: uri }
        cancel_url: { type: string, format: uri }

    PortalSessionCreate:
      type: object
      required: [return_url]
      properties:
        return_url: { type: string, format: uri }

    RedirectSession:
      type: object
      required: [url, expires_at]
      properties:
        url: { type: string, format: uri }
        expires_at: { type: string, format: date-time }

    # ── Audit ──
    AuditAction:
      type: string
      enum:
        - user.created
        - user.updated
        - user.email_changed
        - user.suspended
        - user.reactivated
        - user.deleted
        - user.expired
        - user.erasure_requested
        - user.erasure_cancelled
        - user.erased
        - user.password_changed
        - user.password_reset
        - user.mfa_enabled
        - user.mfa_disabled
        - user.mfa_reset
        - user.sessions_revoked
        - audit.exported
        - application.retired
        - application.deleted
        - profile.avatar_changed
        - application.icon_changed
        - entitlement.granted
        - entitlement.disabled
        - entitlement.revoked
        - entitlement.expired
        - entitlement.updated
        - entitlement.member_scope_changed
        - entitlement.deleted
        - role.created
        - role.updated
        - role.deleted
        - role.assigned
        - role.removed
        - profile.updated
        - settings.updated
        - application.created
        - application.updated
        - application.review_status_changed
        - organization.created
        - organization.updated
        - organization.member_invited
        - organization.member_added
        - organization.member_removed
        - organization.member_role_changed
        - organization.scim_token_created
        - organization.scim_token_revoked
        - subscription.created
        - subscription.status_changed
        - subscription.members_changed
        - tenant.tier_change_requested
        - tenant.tier_change_request_updated
        - tenant.tier_changed
        - api_key.created
        - api_key.updated
        - api_key.rotated
        - api_key.revoked
        - api_key.restrict_destructive_disabled
        - webhook.created
        - webhook.updated
        - webhook.deleted
        - webhook.secret_rotated
        - push_credential.created
        - push_credential.updated
        - push_credential.deleted
        - device.deleted
        - platform.settings_updated
        - user.passkey_added
        - user.passkey_removed

    AuditTargetType:
      type: string
      enum: [user, entitlement, role, role_assignment, application, organization, subscription, tenant, tier_change_request, api_key, webhook, push_credential, device, audit_export, platform_settings]

    PlatformAnnouncement:
      type: object
      required: [text, level]
      additionalProperties: false
      properties:
        text: { type: string, minLength: 1, maxLength: 500 }
        level: { type: string, enum: [info, warning, critical] }

    PlatformSettings:
      type: object
      description: >-
        signup_open and announcement are always present. client_flags needs
        an authenticated caller; read_only and updated_at need
        operations.view.
      required: [signup_open, announcement]
      properties:
        signup_open: { type: boolean, description: "false closes POST /auth/signup (403 signup_closed). Invitations still work." }
        announcement:
          description: A banner for clients to show, or null.
          oneOf:
            - $ref: "#/components/schemas/PlatformAnnouncement"
            - type: "null"
        client_flags:
          type: object
          description: Remote-configurable client feature flags, flag name to boolean. At most 100.
          additionalProperties: { type: boolean }
        read_only: { type: boolean, description: "true answers every non-GET from a caller without superadmin with 503 service_unavailable and Retry-After: 300. Sign-in, refresh, logout, session revocation, and token issuance stay open." }
        updated_at: { type: string, format: date-time }

    PlatformSettingsWrite:
      type: object
      properties:
        signup_open: { type: boolean }
        read_only: { type: boolean }
        client_flags:
          type: object
          description: "Merged one level deep: each flag sent replaces that flag, and a flag sent as null is removed. Names match ^[a-z][a-z0-9_]{0,63}$; at most 100 after the merge."
          additionalProperties: { type: [boolean, "null"] }
        announcement:
          description: Replaces the announcement; null clears it.
          oneOf:
            - $ref: "#/components/schemas/PlatformAnnouncement"
            - type: "null"

    AuditExportRequest:
      type: object
      description: "The same filters as GET /audit-events, as body fields, plus format."
      properties:
        format: { type: string, enum: [ndjson, csv], default: ndjson }
        target_user_id: { type: string, description: "A User id, or me." }
        target_type: { $ref: "#/components/schemas/AuditTargetType" }
        target_id: { type: string }
        actor_id: { type: string }
        actor_type: { type: string, enum: [user, api_key, system] }
        action: { type: array, items: { $ref: "#/components/schemas/AuditAction" }, maxItems: 100 }
        application_id: { type: string }
        organization_id: { type: string }
        tenant_id: { type: string }
        request_id: { type: string, maxLength: 128 }
        since: { type: string, format: date-time }
        until: { type: string, format: date-time }

    AuditExport:
      type: object
      properties:
        id: { type: string }
        status: { type: string, enum: [pending, running, ready, failed] }
        format: { type: string, enum: [ndjson, csv] }
        filters: { type: object, description: "The filters as requested, with target_user_id me resolved to the caller's id." }
        row_count: { type: [integer, "null"], description: "Set once ready." }
        download_url: { type: [string, "null"], format: uri, description: "Set once ready. Signed, valid for 15 minutes; fetch the export again for a fresh one." }
        download_url_expires_at: { type: [string, "null"], format: date-time }
        error: { type: [string, "null"], description: "Set when failed: too_many_rows (over 200,000), too_large (over 400 MB), or internal_error." }
        created_at: { type: string, format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        expires_at: { type: string, format: date-time, description: "When the file and this record are deleted: 7 days after the request." }
        test_mode: { type: boolean }

    UsageReport:
      type: object
      required: [since, until, granularity, group_by, data]
      properties:
        since: { type: string, format: date }
        until: { type: string, format: date }
        granularity: { type: string, enum: [day, week, month] }
        group_by: { type: array, items: { type: string, enum: [application, api_key, operation] } }
        data:
          type: array
          description: "Oldest period first, then by the grouped dimensions. Periods and groups with no requests are omitted."
          items: { $ref: "#/components/schemas/UsageRow" }

    UsageRow:
      type: object
      required: [period, requests, errors_4xx, errors_5xx, latency_ms_avg, latency_ms_max]
      properties:
        period: { type: string, format: date, description: "The bucket's first day (UTC)." }
        application_id: { type: [string, "null"], description: "Only with group_by=application. null for requests that didn't resolve to exactly one Application." }
        api_key_id: { type: [string, "null"], description: "Only with group_by=api_key. null for requests made without an API Key." }
        operation: { type: string, description: "Only with group_by=operation. The operationId." }
        requests: { type: integer }
        errors_4xx: { type: integer }
        errors_5xx: { type: integer }
        latency_ms_avg: { type: integer, description: "Mean latency, rounded to the millisecond." }
        latency_ms_max: { type: integer }

    Stats:
      type: object
      required: [tenant_id, generated_at, users, applications, entitlements, subscriptions, organizations,
                 webhook_subscriptions, push_credentials, tier_change_requests]
      properties:
        tenant_id: { type: [string, "null"], description: "The Tenant the counts are confined to, or null for platform-wide." }
        generated_at: { type: string, format: date-time, description: "When these counts were computed. At most a minute old." }
        users:
          description: "Users by status. null when tenant_id is set."
          oneOf:
            - type: "null"
            - type: object
              required: [active, invited, suspended, deleted]
              properties:
                active: { type: integer }
                invited: { type: integer }
                suspended: { type: integer }
                deleted: { type: integer }
        applications:
          type: object
          required: [by_review_status, by_access_tier]
          properties:
            by_review_status:
              type: object
              required: [approved, pending_review, rejected, suspended, retired]
              properties:
                approved: { type: integer }
                pending_review: { type: integer }
                rejected: { type: integer }
                suspended: { type: integer }
                retired: { type: integer }
            by_access_tier:
              type: object
              required: [free, standard]
              properties:
                free: { type: integer }
                standard: { type: integer }
        entitlements:
          type: object
          description: Entitlements by status.
          required: [active, disabled, expired, revoked]
          properties:
            active: { type: integer }
            disabled: { type: integer }
            expired: { type: integer }
            revoked: { type: integer }
        subscriptions:
          description: "Covering subscriptions (active, trialing, or past_due) by subscriber type. null when tenant_id is set."
          oneOf:
            - type: "null"
            - type: object
              required: [user, organization]
              properties:
                user: { type: integer }
                organization: { type: integer }
        organizations:
          description: "Organizations by status. null when tenant_id is set."
          oneOf:
            - type: "null"
            - type: object
              required: [active, suspended]
              properties:
                active: { type: integer }
                suspended: { type: integer }
        webhook_subscriptions:
          type: object
          description: Webhook subscriptions by status.
          required: [healthy, unhealthy, disabled]
          properties:
            healthy: { type: integer }
            unhealthy: { type: integer }
            disabled: { type: integer }
        push_credentials:
          type: object
          description: Applications' push credentials by status.
          required: [active, failed, disabled]
          properties:
            active: { type: integer }
            failed: { type: integer }
            disabled: { type: integer }
        tier_change_requests:
          type: object
          required: [open]
          properties:
            open: { type: integer, description: "Requests that are pending, scheduled, or in_progress." }

    ScimToken:
      type: object
      properties:
        id: { type: string }
        organization_id: { type: string }
        token: { type: string, description: "The secret (scim_…). Only in the response that creates it." }
        base_url: { type: string, format: uri, description: "The SCIM 2.0 base URL to give the identity provider." }
        created_by: { type: string }
        created_at: { type: string, format: date-time }
        last_used_at: { type: [string, "null"], format: date-time }
        test_mode: { type: boolean }

    AuditEvent:
      type: object
      properties:
        id: { type: string }
        action: { $ref: "#/components/schemas/AuditAction" }
        actor: { $ref: "#/components/schemas/Actor" }
        target:
          type: object
          properties:
            type: { $ref: "#/components/schemas/AuditTargetType" }
            id: { type: string }
        target_user_id: { type: [string, "null"] }
        application_id: { type: [string, "null"] }
        organization_id: { type: [string, "null"] }
        tenant_id: { type: [string, "null"] }
        before: { type: [object, "null"] }
        after: { type: [object, "null"] }
        request_id: { type: [string, "null"] }
        timestamp: { type: string, format: date-time }

    AuditEventPage:
      allOf:
        - $ref: "#/components/schemas/PageEnvelope"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/AuditEvent" } }

    # ── Webhooks ──
    WebhookEvent:
      type: string
      enum:
        - access.granted
        - access.revoked
        - entitlement.granted
        - entitlement.disabled
        - entitlement.revoked
        - entitlement.expired
        - entitlement.updated
        - entitlement.member_scope_changed
        - entitlement.deleted
        - role.assigned
        - role.removed
        - user.suspended
        - user.reactivated
        - user.deleted
        - user.expired
        - organization.member_added
        - organization.member_removed
        - application.review_status_changed
        - device.registered
        - device.deactivated
        - device.deleted
        - notification.push_failed
        - push_credential.failed
        - webhook.test

    AccessEventReason:
      type: string
      enum:
        - entitlement_granted
        - entitlement_started
        - entitlement_enabled
        - entitlement_disabled
        - entitlement_revoked
        - entitlement_expired
        - entitlement_deleted
        - org_grant_changed
        - org_member_added
        - org_member_removed
        - user_suspended
        - user_reactivated
        - user_deleted
        - test_user_expired
        - test_user_extended
        - application_retired

    WebhookEventEnvelope:
      type: object
      description: "Body POSTed to a subscriber. Signed with Substratal-Signature: t=<unix>,v1=<hex HMAC-SHA256 of '{t}.{raw body}'>."
      required: [id, type, api_version, created_at, test_mode, data]
      properties:
        id: { type: string, description: "wev_… — stable across retries; dedupe on it" }
        type: { $ref: "#/components/schemas/WebhookEvent" }
        api_version: { type: string, example: "2026-10-05" }
        created_at: { type: string, format: date-time }
        application_id: { type: [string, "null"] }
        test_mode: { type: boolean }
        data: { type: object }

    Webhook:
      type: object
      properties:
        id: { type: string }
        scope: { type: string, description: "'platform' or an application_id" }
        url: { type: string, format: uri }
        events: { type: array, items: { type: string } }
        description: { type: [string, "null"] }
        status: { type: string, enum: [healthy, unhealthy, disabled] }
        api_version: { type: string }
        test_mode: { type: boolean }
        consecutive_failures: { type: integer }
        last_delivery_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    WebhookWithSecret:
      allOf:
        - $ref: "#/components/schemas/Webhook"
        - type: object
          properties:
            signing_secret: { type: string }

    WebhookPage:
      allOf:
        - $ref: "#/components/schemas/PageEnvelope"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/Webhook" } }

    WebhookDelivery:
      type: object
      properties:
        id: { type: string }
        event_id: { type: string }
        event_type: { $ref: "#/components/schemas/WebhookEvent" }
        attempt: { type: integer, minimum: 1, maximum: 6 }
        status: { type: string, enum: [pending, succeeded, failed] }
        response_status: { type: [integer, "null"] }
        duration_ms: { type: [integer, "null"] }
        error: { type: [string, "null"] }
        next_retry_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    WebhookDeliveryListItem:
      allOf:
        - $ref: "#/components/schemas/WebhookDelivery"
        - type: object
          properties:
            subscription_id: { type: string }
            scope: { type: string, description: "The subscription's scope: an application_id, or 'platform'." }

    WebhookDeliveryPage:
      allOf:
        - $ref: "#/components/schemas/PageEnvelope"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/WebhookDeliveryListItem" } }

    # ── Notifications ──
    PushProvider:
      type: string
      enum: [apns, fcm, web_push]

    PushCredentialCreate:
      type: object
      description: "Fields depend on provider. apns: team_id, key_id, topic, private_key. fcm: service_account. web_push: subject (the VAPID key pair is generated)."
      required: [provider]
      properties:
        provider: { $ref: "#/components/schemas/PushProvider" }
        team_id: { type: string, minLength: 10, maxLength: 10, description: "apns" }
        key_id: { type: string, minLength: 10, maxLength: 10, description: "apns" }
        topic: { type: string, maxLength: 255, description: "apns: the app's bundle id" }
        private_key: { type: string, writeOnly: true, description: "apns: the .p8 file's contents (PEM PKCS#8, P-256). Write-only." }
        service_account: { type: object, writeOnly: true, description: "fcm: the service-account JSON key (type, project_id, private_key, client_email). Write-only." }
        subject: { type: string, maxLength: 255, description: "web_push: a mailto: or https: contact URI (RFC 8292)" }

    PushCredentialUpdate:
      type: object
      properties:
        team_id: { type: string, minLength: 10, maxLength: 10 }
        key_id: { type: string, minLength: 10, maxLength: 10 }
        topic: { type: string, maxLength: 255 }
        private_key: { type: string, writeOnly: true }
        service_account: { type: object, writeOnly: true }
        subject: { type: string, maxLength: 255 }
        status: { type: string, enum: [active], description: "Only failed → active." }

    PushCredential:
      type: object
      required: [id, application_id, provider, status]
      properties:
        id: { type: string, description: "pcr_…" }
        application_id: { type: string }
        provider: { $ref: "#/components/schemas/PushProvider" }
        status: { type: string, enum: [active, failed, disabled], description: "disabled: the Application was retired." }
        team_id: { type: string, description: "apns" }
        key_id: { type: string, description: "apns" }
        topic: { type: string, description: "apns" }
        project_id: { type: string, description: "fcm" }
        client_email: { type: string, description: "fcm" }
        subject: { type: string, description: "web_push" }
        vapid_public_key: { type: string, description: "web_push: base64url uncompressed P-256 point, for PushManager.subscribe({applicationServerKey})" }
        failure_reason: { type: [string, "null"] }
        failed_at: { type: [string, "null"], format: date-time }
        last_used_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    PushCredentialList:
      type: object
      required: [data]
      properties:
        data: { type: array, maxItems: 3, items: { $ref: "#/components/schemas/PushCredential" } }

    PushCredentialPage:
      allOf:
        - $ref: "#/components/schemas/PageEnvelope"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/PushCredential" } }

    DeviceStatus:
      type: string
      enum: [active, inactive]

    DeviceInactiveReason:
      type: string
      enum: [unregistered, session_revoked, user_suspended, user_deleted, test_user_expired, stale, limit, credential_removed, application_retired]

    WebPushSubscription:
      type: object
      description: "The PushSubscription from PushManager.subscribe(), as JSON."
      required: [endpoint, keys]
      properties:
        endpoint: { type: string, format: uri, maxLength: 2048 }
        keys:
          type: object
          required: [p256dh, auth]
          properties:
            p256dh: { type: string }
            auth: { type: string }

    DeviceRegistration:
      type: object
      required: [application_id, provider]
      properties:
        application_id: { type: string }
        provider: { $ref: "#/components/schemas/PushProvider" }
        token: { type: string, minLength: 1, maxLength: 4096, writeOnly: true, description: "apns (hex, 64–200 characters) and fcm only" }
        subscription: { $ref: "#/components/schemas/WebPushSubscription" }
        environment: { type: string, enum: [production, sandbox], description: "apns only, required for it" }
        name: { type: [string, "null"], maxLength: 100 }
        app_version: { type: [string, "null"], maxLength: 50 }

    DeviceUpdate:
      type: object
      properties:
        token: { type: string, minLength: 1, maxLength: 4096, writeOnly: true }
        subscription: { $ref: "#/components/schemas/WebPushSubscription" }
        environment: { type: string, enum: [production, sandbox] }
        name: { type: [string, "null"], maxLength: 100 }
        app_version: { type: [string, "null"], maxLength: 50 }

    Device:
      type: object
      required: [id, user_id, application_id, provider, token_hint, status]
      properties:
        id: { type: string, description: "dvc_…" }
        user_id: { type: string }
        application_id: { type: string }
        provider: { $ref: "#/components/schemas/PushProvider" }
        environment: { type: [string, "null"], enum: [production, sandbox, null] }
        token_hint: { type: string, example: "…9f3a", description: "The token itself is never returned." }
        name: { type: [string, "null"] }
        app_version: { type: [string, "null"] }
        session_id: { type: [string, "null"], description: "The registering session; null when an app key registered it." }
        status: { $ref: "#/components/schemas/DeviceStatus" }
        inactive_reason:
          oneOf:
            - $ref: "#/components/schemas/DeviceInactiveReason"
            - type: "null"
        last_registered_at: { type: string, format: date-time }
        last_delivery_at: { type: [string, "null"], format: date-time }
        test_mode: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    DevicePage:
      allOf:
        - $ref: "#/components/schemas/PageEnvelope"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/Device" } }

    DeviceTestResult:
      type: object
      required: [device_id, status]
      properties:
        device_id: { type: string }
        status: { type: string, enum: [sent, failed] }
        provider_status: { type: [integer, "null"], description: "The provider's HTTP status; null on a timeout or connection error." }
        error: { type: [string, "null"], description: "The provider's reason code, e.g. BadDeviceToken, UNREGISTERED, InvalidProviderToken, or timeout." }
        duration_ms: { type: integer }

    NotificationPriority:
      type: string
      enum: [normal, high]

    NotificationSource:
      type: string
      enum: [application, platform]

    NotificationState:
      type: string
      enum: [unread, read, archived]

    NotificationPushStatus:
      type: string
      enum: [queued, sent, failed, skipped]

    NotificationSkipReason:
      type: string
      enum: [push_not_requested, push_disabled, no_devices, no_credentials, credential_failed, expired]

    NotificationCreate:
      type: object
      required: [title, category]
      properties:
        title: { type: string, minLength: 1, maxLength: 120 }
        body: { type: [string, "null"], maxLength: 1000 }
        category: { type: string, maxLength: 64, pattern: "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)*$", description: "Sender-defined. security., account., and billing. are reserved for platform alerts." }
        priority: { $ref: "#/components/schemas/NotificationPriority" }
        push: { type: boolean, default: true, description: "false writes to the inbox without a push." }
        data:
          type: object
          maxProperties: 20
          additionalProperties: { type: string }
          description: "String values only (FCM's data payload carries only strings); 2 KB serialized; the key substratal is reserved."
        action_url: { type: [string, "null"], maxLength: 2048 }
        badge: { type: [integer, "null"], minimum: 0, maximum: 99999 }
        sound: { type: [string, "null"], enum: [default, null], description: "Defaults to default for high priority, null (silent) for normal." }
        collapse_key: { type: [string, "null"], maxLength: 64, pattern: "^[A-Za-z0-9._-]+$" }
        ttl_seconds: { type: integer, minimum: 0, maximum: 2419200, default: 86400 }
        application_id: { type: string, description: "Only for a platform notifications.send caller, and required for one." }

    NotificationPush:
      type: object
      required: [requested, status]
      properties:
        requested: { type: boolean }
        status: { $ref: "#/components/schemas/NotificationPushStatus" }
        devices_targeted: { type: integer, minimum: 0 }
        devices_sent: { type: integer, minimum: 0 }
        devices_failed: { type: integer, minimum: 0 }
        skip_reason:
          oneOf:
            - $ref: "#/components/schemas/NotificationSkipReason"
            - type: "null"

    Notification:
      type: object
      required: [id, user_id, source, category, priority, title, push, state, created_at]
      properties:
        id: { type: string, description: "ntf_…" }
        user_id: { type: string }
        application_id: { type: [string, "null"], description: "null for a platform alert" }
        source: { $ref: "#/components/schemas/NotificationSource" }
        category: { type: string }
        priority: { $ref: "#/components/schemas/NotificationPriority" }
        title: { type: string }
        body: { type: [string, "null"] }
        data: { type: object, additionalProperties: { type: string } }
        action_url: { type: [string, "null"] }
        badge: { type: [integer, "null"] }
        sound: { type: [string, "null"] }
        collapse_key: { type: [string, "null"] }
        ttl_seconds: { type: integer }
        push: { $ref: "#/components/schemas/NotificationPush" }
        state: { $ref: "#/components/schemas/NotificationState" }
        read_at: { type: [string, "null"], format: date-time }
        archived_at: { type: [string, "null"], format: date-time }
        sender:
          type: object
          required: [type, id]
          properties:
            type: { type: string, enum: [api_key, user, system] }
            id: { type: string }
        test_mode: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    NotificationDelivery:
      type: object
      properties:
        id: { type: string, description: "ndl_…" }
        device_id: { type: [string, "null"], description: "null once the Device is deleted" }
        provider: { $ref: "#/components/schemas/PushProvider" }
        status: { type: string, enum: [pending, sent, failed, skipped] }
        attempts: { type: integer, minimum: 0, maximum: 4 }
        error: { type: [string, "null"] }
        sent_at: { type: [string, "null"], format: date-time }

    NotificationWithDeliveries:
      allOf:
        - $ref: "#/components/schemas/Notification"
        - type: object
          properties:
            deliveries: { type: array, items: { $ref: "#/components/schemas/NotificationDelivery" } }

    NotificationPage:
      allOf:
        - $ref: "#/components/schemas/PageEnvelope"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/Notification" } }

    NotificationUnreadCount:
      type: object
      required: [unread_count, applications]
      properties:
        unread_count: { type: integer, minimum: 0 }
        applications:
          type: array
          items:
            type: object
            required: [application_id, unread_count]
            properties:
              application_id: { type: [string, "null"], description: "null for platform alerts" }
              unread_count: { type: integer, minimum: 0 }

    # ── API Keys ──
    ApiKeyWrite:
      type: object
      required: [name, scope, permissions, mode]
      properties:
        name: { type: string, maxLength: 100 }
        scope: { type: string, description: "'platform' or an application_id" }
        permissions: { type: array, items: { type: string } }
        mode:
          type: string
          enum: [live, test]
          description: Fixed at creation.
        intended_use:
          type: string
          enum: [service, agent]
          default: service
          description: Sets the default for restrict_destructive (true for agent, false for service).
        restrict_destructive:
          type: boolean
          description: "When true, every operation marked x-substratal-destructive is rejected with 403 destructive_operation_restricted."
        expires_at: { type: [string, "null"], format: date-time }
        rate_limit_per_minute:
          type: [integer, "null"]
          minimum: 1
          maximum: 10000
          description: >-
            Replaces the default 100 requests/minute for this key. Null (the
            default) means the platform default. Operations with their own
            ceiling keep it. Only platform api_keys.manage may send it; anyone
            else gets 403 forbidden.

    ApiKey:
      allOf:
        - $ref: "#/components/schemas/ApiKeyWrite"
        - type: object
          properties:
            id: { type: string }
            secret_hint: { type: string, example: "…6c8e" }
            created_by:
              type: object
              description: "Always a User: API Keys can't create API Keys."
              properties:
                type: { type: string, enum: [user] }
                id: { type: string }
            last_used_at: { type: [string, "null"], format: date-time }
            created_at: { type: string, format: date-time }
            revoked_at: { type: [string, "null"], format: date-time }

    ApiKeyWithSecret:
      allOf:
        - $ref: "#/components/schemas/ApiKey"
        - type: object
          properties:
            secret: { type: string }

    ApiKeyPage:
      allOf:
        - $ref: "#/components/schemas/PageEnvelope"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/ApiKey" } }
