openapi: 3.1.0
info:
  title: jamb-signal-cli API
  version: 1.0.0
  description: Signal CLI server API — user, admin, service, and webhook endpoints
  license:
    name: ISC

servers:
  - url: /

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: Supabase JWT access token (admin routes)
    SignalUserAuth:
      type: apiKey
      in: header
      name: Authorization
      description: Signal user authentication (api routes)
    ServiceAccountAuth:
      type: apiKey
      in: header
      name: Authorization
      description: Service account credentials (service routes)

  schemas:
    PhoneNumberProvider:
      type: string
      enum: [telnyx, carrierx]
      description: >
        Which phone-number provider a number comes from, or is to be acquired
        from.


        On a REQUEST it is optional: omit it for the default (`telnyx`), which
        is what every shipped client does. It is a guarantee rather than a hint
        — a request naming `carrierx` reuses only CarrierX inventory and buys
        new rather than reaching across to a free Telnyx number, and the same
        holds the other way. A provider this deployment is not configured for
        answers 422, which is permanent: retrying will not succeed.


        On a RESPONSE it names the provider that actually answered, or that
        holds the number. Release, attach and detach are routed by the provider
        recorded against the number, never by a parameter — so this field is
        what a client keeps in order to address the number later.
    AgentAccount:
      type: object
      properties:
        aci:
          type: string
        username:
          type: string
        container:
          type: object

    AgentProfileName:
      type: object
      description: >-
        A Signal profile name, as the pair Signal stores it in. Not a display
        string: the two halves cannot be recovered from a join, and consumers
        project them differently — a profile keeps them apart, a speaker label
        joins them, SMS/RCS copy uses only the given half.
      properties:
        givenName:
          type: string
          description: The given half. Never empty.
        familyName:
          type: string
          description: The family half. Empty for a one-word name.
      required: [givenName, familyName]

    PhoneNumber:
      type: object
      properties:
        phoneNumber:
          type: string

    ReservedPhoneNumber:
      type: object
      description: >
        A number this request just acquired, and the provider it was acquired
        from. Separate from PhoneNumber because `provider` is only meaningful
        when the acquisition happened here — the several other responses that
        return a bare phoneNumber have no such answer to give.
      required: [phoneNumber, provider]
      properties:
        phoneNumber:
          type: string
        provider:
          $ref: '#/components/schemas/PhoneNumberProvider'

    TokenResponse:
      type: object
      properties:
        access_token:
          type: string
        token_type:
          type: string
        expires_in:
          type: integer
        expires_at:
          type: integer
        refresh_token:
          type: string

    RateLimit:
      type: object
      properties:
        key:
          type: string
        count:
          type: integer
        ttl:
          type: integer

    OrgResponse:
      type: object
      required: [id, name, createdAt]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        createdAt:
          type: string
          format: date-time

    OrgWithStatus:
      type: object
      required: [id, name, createdAt, status]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        createdAt:
          type: string
          format: date-time
        status:
          type: string
          enum: [pending, active]
          description: The caller's membership status in this org

    OrgMember:
      type: object
      required: [id, name, phone, role, status]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          description: >
            Display name: the per-org override (org_members.name) when set,
            otherwise the global users.name.
        phone:
          type: string
        aci:
          type: string
          description: Signal ACI, present when known
        role:
          type: string
          enum: [admin, member]
        status:
          type: string
          enum: [pending, active]
        title:
          type: string
          description: Per-org free-text job title (e.g. "Landscape Manager"); omitted when unset
        department:
          type: string
          description: Per-org department; omitted when unset
        replayHistoryOnJoin:
          type: boolean
          description: >
            Per-member flag: when true, the owner agent replays org-group
            history to this member when they are added to the org's Signal
            group (spec B3b).
        businessPhone:
          type: string
          description: >
            The member's provisioned Telnyx business number (E.164), read
            through from provisioning (FR-D1). Omitted when the member has no
            provisioned number. Not a stored membership column.

    MemberInput:
      type: object
      description: >
        Identity is the ACI (a registered/Jamb contact the client already
        resolved) or a phone (an external contact not yet on Signal); at least
        one of aci/phone is required.
      properties:
        aci:
          type: string
          format: uuid
          description: Signal ACI of a registered/Jamb contact
        phone:
          type: string
          minLength: 1
          description: Phone number of an external contact not yet on Signal
        role:
          type: string
          enum: [admin, member]
          default: member
        name:
          type: string
          minLength: 1
          description: >
            Per-org display name for this member (org_members.name); overrides
            the global users.name in this org's directory. Also seeds the global
            users.name when this add creates a brand-new user record.
        title:
          type: string
        department:
          type: string
        replayHistoryOnJoin:
          type: boolean
          default: false
          description: >
            When true, the owner agent replays org-group history to this member
            when they join the org's Signal group (spec B3b). Per-invite.
      anyOf:
        - required: [aci]
        - required: [phone]

    OrgWithMembers:
      type: object
      required: [id, name, createdAt, members]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        createdAt:
          type: string
          format: date-time
        signalGroupId:
          type: [string, 'null']
          description: >-
            The org's Signal group id (base64url), or null until the owner agent
            has created the org group. Lets the client identify and pin the org
            group (§7.B Part B).
        allUsersUserGroupId:
          type: [string, 'null']
          format: uuid
          description: >-
            The org's allUsers user-group id (§7.H) — an ordinary user-group (the
            org's active-member roster). Track it like any user-group by linking it
            to a Signal group via POST /api/group/{groupId}/user-groups. Null only
            before it is seeded (never, for a live org).
        members:
          type: array
          items:
            $ref: '#/components/schemas/OrgMember'

    TrackedUserGroup:
      type: object
      description: >
        A user-group a Signal group is tracking, with its live member count (§7.H,
        B1). orgId is the owning org for an org-bound user-group (its allUsers roster
        or a subset group) and null for a standalone group; isOrgAll flags the org's
        allUsers group (whose name is the org name).
      required: [userGroupId, name, memberCount, orgId, isOrgAll]
      properties:
        userGroupId:
          type: string
          format: uuid
        name:
          type: string
        memberCount:
          type: integer
          description: Count of the user-group's members
        orgId:
          type: [string, 'null']
          format: uuid
          description: Owning org for an org-bound user-group; null for a standalone group
        isOrgAll:
          type: boolean
          description: True when this is the owning org's allUsers roster group

    UserGroupMemberRef:
      type: object
      description: A member reference inside a GroupView (§7.H).
      required: [userId]
      properties:
        userId:
          type: string
          format: uuid

    GroupView:
      type: object
      description: >
        The user-group projection (§7.H) — the group's columns plus its member
        set. Phase 1 always returns orgId=null and isOrgAll=false (org binding is
        a later phase).
      required: [id, name, createdBy, orgId, isOrgAll, createdAt, memberCount, members]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        createdBy:
          type: string
          format: uuid
          description: The user id of the group's creator (Phase-1 authz owner)
        orgId:
          type: [string, 'null']
          format: uuid
          description: Owning org id; always null in Phase 1
        isOrgAll:
          type: boolean
          description: Whether this is an org's auto-managed allUsers group; always false in Phase 1
        createdAt:
          type: string
          format: date-time
        memberCount:
          type: integer
        members:
          type: array
          items:
            $ref: '#/components/schemas/UserGroupMemberRef'

    UserGroupReference:
      type: object
      description: A group→entity reference (§7.H) — the reusable-ACL edge.
      required: [userGroupId, entityType, entityId]
      properties:
        userGroupId:
          type: string
          format: uuid
        entityType:
          type: string
          enum: [org, project, task]
        entityId:
          type: string
          format: uuid

    AnsweringService:
      type: object
      description: >
        An AI-assisted answering service — a phone number Jamb AI answers on the
        customer's behalf. The service's behavior lives in the AI server as an
        instruction document, which Jamb addresses on your behalf; `id` identifies
        the service and is not the instruction's id.
      required: [id, phoneNumber, userGroupId, name, createdAt, updatedAt]
      properties:
        id:
          type: string
          description: >
            Opaque service identifier. Services created before 2026-08 carry the
            AI server's instruction id here; newer ones carry a uuid. Treat it as
            opaque either way — it addresses the service, not the instruction, and
            is not usable against the AI server.
        phoneNumber:
          type: string
          description: E.164 number the service answers on; unique across live services
        userGroupId:
          type: string
          format: uuid
          description: The org user group whose members are the AI's reachable team
        name:
          type: [string, 'null']
          description: >
            Company name the AI answers as. Null means unset — the AI is given the
            user group's name instead.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    AnsweringServiceDetail:
      type: object
      description: >
        The richer projection returned by the phone-number lookup
        (`GET /service/api/answering-services/phoneNumbers/{phoneNumber}`). A
        superset of AnsweringService, additionally exposing the AI instruction id,
        the derived owning org, and the owner. Not returned by the CRUD endpoints.
      required: [id, phoneNumber, instructionId, orgId, userGroupId, ownerId, name, createdAt, updatedAt]
      properties:
        id:
          type: string
          description: >
            Opaque service identifier (as on AnsweringService) — addresses the
            service, not the AI instruction.
        phoneNumber:
          type: string
          description: E.164 number the service answers on
        instructionId:
          type: string
          description: >
            The AI server's instruction id backing this service — the key the AI
            server is addressed by (distinct from `id`).
        orgId:
          type: string
          format: uuid
          description: >
            The owning org, derived through `userGroupId` → `user_groups.org_id`;
            never stored on the service row.
        userGroupId:
          type: string
          format: uuid
          description: The org user group whose members are the AI's reachable team
        ownerId:
          type: [string, 'null']
          format: uuid
          description: >
            The service owner — the internal users.id of the caller who created it.
            Null only for a legacy row not yet backfilled.
        name:
          type: [string, 'null']
          description: >
            Company name the AI answers as. Null means unset — the AI is given the
            user group's name instead.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    CreateAnsweringService:
      type: object
      required: [phoneNumber, userGroupId]
      properties:
        phoneNumber:
          type: string
          description: >
            The E.164 number to provision. It is ordered from Telnyx as-is (pick an
            available one first via …/provisioning/availablePhoneNumbers); a number
            that cannot be ordered, or that another live service already holds, is a
            409.
        userGroupId:
          type: string
          format: uuid
          description: >
            Must be a live user group bound to the caller's org. A standalone group
            (org_id null) or another org's group is a 400.
        name:
          type: string
          minLength: 1
          description: Company name the AI answers as; defaults to the user group's name

    UpdateAnsweringService:
      type: object
      description: >
        A shallow partial of CreateAnsweringService. An empty body is a no-op that
        returns the current resource unchanged.
      properties:
        phoneNumber:
          type: string
          description: >
            Orders the new number, re-points the service at it, then releases the
            old one. A value equal to the current number is a no-op.
        userGroupId:
          type: string
          format: uuid
          description: Re-points the AI's team; validated exactly as on create
        name:
          type: [string, 'null']
          minLength: 1
          description: Null clears the name, reverting the AI's company name to the user group's

    CallObject:
      type: object
      properties:
        groupId:
          type: string
        callId:
          type: string
        date:
          type: string
          format: date-time

    UnconditionalForward:
      type: [object, 'null']
      properties:
        activate:
          type: string
        deactivate:
          type: string
        check_status:
          type: string

    CarrierInfo:
      type: [object, 'null']
      properties:
        brand:
          type: string
        code_family:
          type: string
        unconditional_forward:
          $ref: '#/components/schemas/UnconditionalForward'

    NumberLookupResult:
      type: object
      properties:
        record_type:
          type: string
          example: number_lookup
        country_code:
          type: string
        national_format:
          type: string
        phone_number:
          type: string
        fraud:
          type: ['null']
        carrier:
          type: [object, 'null']
          properties:
            mobile_country_code:
              type: [string, 'null']
            mobile_network_code:
              type: [string, 'null']
            name:
              type: [string, 'null']
            type:
              type: [string, 'null']
            error_code:
              type: [string, 'null']
            normalized_carrier:
              type: [string, 'null']
        caller_name:
          type: [object, 'null']
          properties:
            caller_name:
              type: [string, 'null']
            error_code:
              type: [string, 'null']
        portability:
          type: [object, 'null']
          properties:
            lrn:
              type: [string, 'null']
            ported_status:
              type: [string, 'null']
            ported_date:
              type: [string, 'null']
            ocn:
              type: [string, 'null']
            line_type:
              type: [string, 'null']
            spid:
              type: [string, 'null']
            spid_carrier_name:
              type: [string, 'null']
            spid_carrier_type:
              type: [string, 'null']
            altspid:
              type: [string, 'null']
            altspid_carrier_name:
              type: [string, 'null']
            altspid_carrier_type:
              type: [string, 'null']
            city:
              type: [string, 'null']
            state:
              type: [string, 'null']
        carrier_info:
          $ref: '#/components/schemas/CarrierInfo'

    UserSettings:
      type: object
      properties:
        voip:
          type: object
          description: >
            Has no settable properties. `mode` was removed along with verified
            caller ID; a body that still sends it is accepted and the field
            ignored.
          properties: {}

    RemoteConfigBody:
      type: object
      required: [percentage]
      properties:
        percentage:
          type: integer
          minimum: 0
          maximum: 100
        defaultValue:
          type: string
        value:
          type: string
        hashKey:
          type: string
        enrolledAcis:
          type: array
          items:
            type: string
            format: uuid

    RemoteConfig:
      allOf:
        - type: object
          required: [name]
          properties:
            name:
              type: string
        - $ref: '#/components/schemas/RemoteConfigBody'

    SignalAccount:
      type: object
      required:
        # The number fields only, deliberately — not every property both handlers happen to fill.
        # These two are the contract this schema makes a promise about: the array is always sent
        # (`[]` when the account holds nothing) and the deprecated field is always sent (`null`
        # in that case), so a client migrating from one to the other can index without an
        # undefined guard. `data`, `agent` and `isOrgAdmin` are genuinely absent sometimes —
        # the first two are `undefined` on an account with no stored data or no agent, and
        # `isOrgAdmin` is added by the single-account route only — so they stay optional.
        - provisionedPhoneNumber
        - provisionedPhoneNumbers
      properties:
        aci:
          type: string
        phoneNumber:
          type: string
        pni:
          type: string
        isDiscoverable:
          type: boolean
        data:
          type: object
        agent:
          type: object
        provisionedPhoneNumber:
          type: [string, 'null']
          deprecated: true
          description: >
            DEPRECATED — one number, where an account may now hold several.


            A single Jamb business number for the account, kept for existing clients and still
            populated. It is resolved unscoped by provider and reports only the account's OLDEST
            live registration (`voip_users` ordered by `created_at`, then `id`), so an account
            holding more than one number is under-reported here and nothing in this field says
            so: the value is indistinguishable from the answer given when that IS the only
            number.


            `null` still means the account holds no live registration at all.


            Do not use it to decide what an account holds. Read `provisionedPhoneNumbers`
            below, which this field is the first element of and which costs the caller nothing
            extra — both come out of the same read.
        provisionedPhoneNumbers:
          type: array
          items:
            type: string
          description: >
            EVERY live Jamb business number the account holds, oldest first (`voip_users` ordered
            by `created_at`, then `id`), and unscoped by provider.


            The replacement for `provisionedPhoneNumber`, which reports only the first of these
            and gives a caller no way to tell that it did. `provisionedPhoneNumbers[0]` is that
            field, exactly — the two are derived from one read of the same ordered rows, so a
            client migrating off the singular field can index this one and see no change.


            `[]` — never absent — when the account holds no live registration, which is the case
            the singular field reports as `null`.


            Personal numbers are not here. These are Jamb business numbers (JBNs); the personal
            number each one rings through to is on the registration, at
            `GET /admin/api/phoneNumbers/registrations?aci={aci}`.
        isOrgAdmin:
          type: boolean
          description: Whether this account holds the org-admin (org-creator) capability.

    SignalAccountRecord:
      type: object
      description: >
        The stored account record, returned RAW — `AccountInfo` as `getAccountFromDb` builds it
        (src/db.ts), with no enrichment.


        Deliberately not `SignalAccount`, which the two `/admin/api/accounts` reads return. That
        one is assembled by `AccountManager` and names things for a client: `aci`, `pni`, a
        device summary under `data`, plus the agent and the account's provisioned numbers, each
        of which costs a lookup. This is the DynamoDB row: `uuid` not `aci`,
        `phoneNumberIdentifier` not `pni`, the full `accountData` not a summary, and nothing that
        would need a second read.


        The two are separate schemas because the difference is not an oversight to be converged.
        The only consumer of this route models it as its own type already
        (`SignalAccountDetails` in jamb-signal-admin-server), reading `uuid`, `isDiscoverable`
        and `accountData` — enriching the handler to match `SignalAccount` would add per-request
        lookups nothing asks for.
      required:
        - uuid
        - isDiscoverable
        - accountData
        - phoneNumber
        - phoneNumberIdentifier
        - version
      properties:
        uuid:
          type: string
          format: uuid
          description: The account's ACI. Named `uuid` here, and `aci` on `SignalAccount`.
        isDiscoverable:
          type: boolean
          description: Whether the account is discoverable by phone number. Defaults to false.
        accountData:
          type: object
          description: >
            The account's stored data, in full — including the complete device list with its
            per-device credentials. NOT the redacted `data` that `SignalAccount` carries, which
            passes the devices through `summarizeDevices` first.
        phoneNumber:
          type: string
          description: The account's registered phone number (its Signal number, not a JBN).
        phoneNumberIdentifier:
          type: string
          format: uuid
          description: The PNI. Named `pni` on `SignalAccount`.
        usernameHash:
          type: string
          description: Base64. Absent when the account has no username.
        unidentifiedAccessKey:
          type: string
          description: Base64. Absent when the account has none stored.
        usernameLinkUuid:
          type: string
          format: uuid
          description: Absent when the account has no username link.
        version:
          type: integer
          description: The stored record's version. 0 when the row carries none.

    VoipRegistrationRow:
      type: object
      description: >
        One `voip_users` pairing — a Jamb business number bound to the personal number that
        actually rings.


        Shared by the registrations listing and the single-registration detail, deliberately: the
        detail is opened from a listing row, and a row and its own detail reporting different
        providers for one number is the failure that would make the detail screen worse than the
        calls it replaces. One schema here, one column list in the reader.
      required:
        - id
        - businessNumber
        - personalNumber
        - aci
        - recordedProvider
        - effectiveProvider
        - provisionedByOrgId
        - createdAt
        - deletedAt
      properties:
        id:
          type: string
          format: uuid
          description: >
            The registration's own id, and the key the detail route takes. Digits are not unique
            across this table — a number given up and re-provisioned has a row per registration —
            so this is what identifies one.
        businessNumber:
          type: string
          description: The Jamb business number (E.164)
        personalNumber:
          type: string
          description: The owner's own phone, which the JBN rings (E.164)
        aci:
          type: [string, 'null']
          description: Null for a pending org member, who has not registered
        # Plain strings rather than the PhoneNumberProvider enum, as the sibling
        # listings model theirs: these are READ BACK from columns that are plain
        # TEXT on purpose, so a row written by another deployment can name a
        # provider this build has never heard of and still read rather than
        # explode the query.
        recordedProvider:
          type: [string, 'null']
          description: >
            What the row records — stamped once at provisioning and never revised.
        effectiveProvider:
          type: [string, 'null']
          description: >
            What the live acquisition says, which is what the routing queries resolve. It differs
            from `recordedProvider` for a ported number, and seeing the difference is the point.


            Resolved from the live inventory row only for a LIVE registration. A given-up
            registration reports what it RECORDED: the inventory is keyed by digits while each row
            describes one acquisition, so the current holding says nothing about a registration
            that already gave the number up.
        provisionedByOrgId:
          type: [string, 'null']
          description: The org that provisioned it; null means the owner's own
        createdAt:
          type: string
          format: date-time
        deletedAt:
          type: [string, 'null']
          format: date-time
          description: When the registration was given up; null while it is live.

    ProxyPoolRow:
      type: object
      description: >
        One number in the call-bridging proxy pool, with how much of the estate currently reaches
        somebody through it.


        A proxy is also a `phone_number_inventory` row — one with `ownerKind=voip_proxy` — so the
        inventory listing pages the same numbers. What it cannot answer is `usedBy`, which lives
        in `voip_proxy_assignments`, and that is the whole reason this listing exists rather than
        an inventory query with a filter.
      required:
        - phoneNumber
        - createdAt
        - usedBy
        - recordedProvider
        - effectiveProvider
      properties:
        phoneNumber:
          type: string
          description: Proxy phone number (E.164)
        createdAt:
          type: string
          format: date-time
          description: When the number joined the pool
        usedBy:
          type: integer
          description: >
            How many Jamb business users currently reach an external party through this proxy — a
            count of active `[user, external contact]` assignments. `0` marks a spare.


            Equal to a count of distinct users: a proxy holds at most one active assignment per
            user, so the two questions have one answer.
        # Plain strings rather than the PhoneNumberProvider enum, as the sibling
        # listings model theirs: these are READ BACK from columns that are plain
        # TEXT on purpose, so a row written by another deployment can name a
        # provider this build has never heard of and still read rather than
        # explode the query. The REQUEST side is the enum, where a caller is
        # naming a provider this deployment must actually run.
        recordedProvider:
          type: [string, 'null']
          description: >
            What the POOL ROW records — stamped when the number joined the pool. Null on a row
            written before pool provenance was recorded at all.
        effectiveProvider:
          type: [string, 'null']
          description: >
            The live acquisition record's provider, falling back to `recordedProvider` — and the
            one ALLOCATION scopes on, so it is the provider that decides which business numbers
            this proxy can stand in for.


            Both are reported because either can be the only one that knows: the pool column is
            never revised and so survives a retired acquisition record, while the inventory
            follows the number through a port. The removed `GET /admin/api/voip/proxy` reported
            the inventory's alone, which read back as null for a proxy allocation treats as
            being at a provider.


            Null means NOTHING resolves the number's provenance — never that it is Telnyx's, and
            never a statement about whether the number is in the pool. Render the absence rather
            than defaulting it; ask for those rows with `provider=none`.

    VoipProxyRoute:
      type: object
      description: >
        One active proxy assignment flattened with its user's pairing — everything needed to read
        a route in either direction.
      required:
        - id
        - personalNumber
        - businessNumber
        - proxyNumber
        - externalNumber
        - proxyProvider
        - businessProvider
      properties:
        id:
          type: string
          format: uuid
          description: >
            The assignment's own id — a stable key for the row, and the second half of the
            boundary a paged listing's `nextCursor` names.
        personalNumber:
          type: string
          description: Personal number that rings (E.164)
        businessNumber:
          type: string
          description: Jamb business number of the user (E.164)
        proxyNumber:
          type: string
          description: Pool proxy assigned for this contact (E.164)
        externalNumber:
          type: string
          description: External party the proxy stands for (E.164)
        proxyProvider:
          type: [string, 'null']
          description: >
            Which provider holds the proxy number. Null when the number has no live inventory
            record — ordinary for numbers acquired before the inventory existed, and never a
            statement about whether the route is valid.
        businessProvider:
          type: [string, 'null']
          description: >
            Which provider holds the business number. Null on the same terms as proxyProvider. No
            provider is reported for personalNumber or externalNumber: those are not Jamb's
            numbers.

    PhoneNumberInventoryRow:
      type: object
      description: >
        One ACQUISITION of a number, not one number. A number Jamb released and later bought
        again has a row per acquisition, each with its own provider id, order and dates
        (specs/phone-number-inventory.md B5, B51).


        `claimToken` and `claimTookOwnership` are deliberately not returned. The first is a
        capability rather than a fact — anything that can present it can give back or settle an
        acquisition still in flight — and the second is only meaningful to the compensation path
        that undoes a taking (B69).
      required:
        - id
        - phoneNumber
        - provider
        - providerNumberId
        - providerOrderId
        - purchasedAt
        - createdAt
        - updatedAt
        - releasedAt
        - ownerKind
        - ownerId
        - claimPendingAt
        - outOfServiceAt
        - lastOwnerKind
        - lastOwnerId
        - resting
        - reusableAt
        - lastDeparture
      properties:
        id:
          type: string
          format: uuid
          description: The acquisition record's id.
        phoneNumber:
          type: string
          description: E.164.
        provider:
          type: string
          description: Which provider the number was acquired from.
          example: telnyx
        providerNumberId:
          type: string
          description: The provider's own id for this number — what release and reconfiguration address.
        providerOrderId:
          type: [string, 'null']
          description: >
            How the acquisition is addressed at the provider, as an OPAQUE token minted by that
            provider's adapter. Decode it against this row's `provider`; do not assume it is a
            bare order id. Telnyx writes `order=<orderId>;sub=<subOrderId>`, narrowing to the sub
            number order its portal actually addresses, or `order=<orderId>` where the sub-order
            is not known, or a bare id for a row written before this tagging existed.
            Null is a normal state: a ported-in number has a port request rather than an order,
            and pre-inventory numbers may have neither.
        purchasedAt:
          type: [string, 'null']
          format: date-time
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        releasedAt:
          type: [string, 'null']
          format: date-time
          description: When Jamb gave the number back to the provider. Null while Jamb still holds it.
        ownerKind:
          type: [string, 'null']
          description: >
            Which product holds the number; null together with `ownerId` means held by nothing.


            NOT a closed set on the way out. The column is plain text and adding a product is a
            code change rather than a migration, so a row written by a newer deployment names a
            kind this build does not know and still reads back. A response validator that
            rejected it would fail an otherwise valid page during a rolling deploy or a rollback.
            The kinds this build understands are `voip_user`, `answering_service`, `voip_proxy`,
            and the retired `assistant` and `agent`; the `ownerKind` query FILTER is limited to
            those, because a filter can only select what it can name.


            Held by nothing is NOT the same as available: ownership and liveness are independent
            facts, and a record retired while unowned carries this same null pair while the number
            is no longer Jamb's at all. Check `releasedAt` — null there and here is a number free
            in Jamb's pool; `resting` narrows that further to the ones no new holder may take yet.


            `assistant` and `agent` are retired products whose historical rows still read back.
        ownerId:
          type: [string, 'null']
          description: >
            The holder's own id for the number, in the holding product's terms. Null together with
            `ownerKind` — see the caution there before reading that as available.
        claimPendingAt:
          type: [string, 'null']
          format: date-time
          description: >
            Set while a taking is in progress and not yet corroborated by the claiming product's
            own record. A value much older than a request's lifetime is an acquisition that never
            completed (B19).
        outOfServiceAt:
          type: [string, 'null']
          format: date-time
          description: >
            When the number stopped being IN SERVICE. Null means it never left service — still
            held, or never assigned at all. It is NOT cleared when the departing holder takes the
            number straight back, nor when the record is retired, so it is not on its own an
            answer to whether the number is free; use `resting`.
        lastOwnerKind:
          type: [string, 'null']
          description: >
            The COLUMN: which product was recorded as giving the number up. Not necessarily the
            last holder — see `lastDeparture`. Open on the way out, for the reason given on
            `ownerKind`.
        lastOwnerId:
          type: [string, 'null']
          description: >
            The COLUMN: who was recorded as giving the number up — the holder that is exempt from
            the quarantine, since the traffic the quarantine protects is its own. For "who had
            this number last", read `lastDeparture` instead.
        resting:
          type: boolean
          description: >
            The number is free, still Jamb's, and inside its quarantine window — so no NEW holder
            may take it yet. False for a held number and for a released one, whatever their
            `outOfServiceAt` says.


            Decided by the database, in the same expression that backs the `resting` filter, so a
            page and the verdict each row in it carries always agree.


            AGREEMENT WITH THE TAKING IS NOT EXACT AT THE BOUNDARY. This is evaluated against the
            database's clock, exactly as the number PICKER is — so a reading and a search agree —
            while the claim that would refuse a number evaluates against the API host's clock. If
            the two hosts differ, then within that offset of an expiry instant this can report
            `true` for a number a claim would now allow, or `false` for one it would still refuse.
            Bounded by the hosts' clock offset at a thirty-day boundary; tracked in jamb-signal-cli#1525.
        reusableAt:
          type: [string, 'null']
          format: date-time
          description: When a resting number becomes takeable by anyone. Null when it is not resting.
        lastDeparture:
          type: object
          description: >
            The last departure this record describes — when service ended and whose it was — as
            ONE pair. Read this rather than `lastOwner*` to answer "who had this number last".


            A row can carry the residue of two departures. A number retired WHILE STILL HELD keeps
            its owner, and retirement stamps nothing but `releasedAt` — so `lastOwnerKind` /
            `lastOwnerId` still describe the departure BEFORE that holder took the number, or
            describe nobody at all. Both halves here come from the same departure; pairing one
            departure's holder with the other's timestamp is wrong in both directions.
          required: [at, holder]
          properties:
            at:
              type: [string, 'null']
              format: date-time
              description: >
                When the number last left service — or, for a record retired without one
                recorded, when Jamb gave it back.


                The fallback is conservative and deliberate: rows predating the departure columns
                have only a retirement date, and a number retired while unowned and never in
                service has that alone. So a non-null value here does NOT prove the number ever
                carried traffic; `outOfServiceAt` is the column that says only that. Null means
                the record says nothing about either.
            holder:
              type: ['object', 'null']
              description: Who held it then. Null when the record does not say.
              required: [kind, id]
              properties:
                kind:
                  type: string
                  description: Open on the way out, for the reason given on `ownerKind`.
                id:
                  type: string

    DynamicConfiguration:
      type: object
      additionalProperties: true
      properties:
        experiments:
          type: object
          additionalProperties: true
        e164Experiments:
          type: object
          additionalProperties: true
        limits:
          type: object
          additionalProperties: true
        remoteDeprecation:
          type: object
          additionalProperties: true
        payments:
          type: object
          additionalProperties: true
        captcha:
          type: object
          additionalProperties: true
        messagePersister:
          type: object
          additionalProperties: true
        registrationConfiguration:
          type: object
          additionalProperties: true
        metricsConfiguration:
          type: object
          additionalProperties: true
        svr2StatusCodesToIgnoreForAccountDeletion:
          type: array
          items:
            type: integer
        svrbStatusCodesToIgnoreForAccountDeletion:
          type: array
          items:
            type: integer
        restDeprecation:
          type: object
          additionalProperties: true
        backup:
          type: object
          additionalProperties: true
        carrierDataLookup:
          type: object
          additionalProperties: true
        grpcAllowList:
          type: object
          additionalProperties: true
        turn:
          type: object
          additionalProperties: true



    Error:
      type: object
      properties:
        error:
          type: string

tags:
  - name: user-agent
    description: User agent management (authenticated Signal user)
  - name: user-groups
    description: Group operations (authenticated Signal user)
  - name: user-voip
    description: VOIP provisioning (authenticated Signal user)
  - name: user-settings
    description: User settings (authenticated Signal user)
  - name: user-org
    description: Org lifecycle and capabilities (authenticated Signal user)
  - name: user-group
    description: Org-linked group tracking (authenticated Signal user)
  - name: user-groups-primitive
    description: User groups — the reusable membership/ACL primitive (authenticated Signal user, §7.H)
  - name: user-answering-services
    description: AI-assisted answering services (authenticated Signal user, org admin + allow-listed)
  - name: auth
    description: Admin authentication
  - name: admin-accounts
    description: Signal account management (admin)
  - name: admin-agents
    description: User agent lifecycle management (admin)
  - name: admin-calls
    description: Call recording and media (admin)
  - name: admin-phone
    description: Phone number provisioning (admin)
  - name: admin-host
    description: Host container management (admin)
  - name: admin-remote-config
    description: Remote config management (admin)
  - name: admin-dynamic-config
    description: Dynamic config management (admin)
  - name: admin-misc
    description: Miscellaneous admin operations
  - name: service
    description: Service account endpoints
  - name: webhooks
    description: External webhook receivers

paths:

  # ─────────────────────────────────────────────
  # Auth — /admin/api/login
  # ─────────────────────────────────────────────

  /admin/api/login:
    post:
      tags: [auth]
      summary: Send OTP login code
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phoneNumber]
              properties:
                phoneNumber:
                  type: string
      responses:
        '204':
          description: OTP sent

    put:
      tags: [auth]
      summary: Verify OTP and obtain session tokens
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phoneNumber, code]
              properties:
                phoneNumber:
                  type: string
                code:
                  type: string
      responses:
        '200':
          description: Session tokens
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenResponse'
        '400':
          description: Invalid OTP

  /admin/api/login/refresh:
    post:
      tags: [auth]
      summary: Refresh access token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refreshToken]
              properties:
                refreshToken:
                  type: string
      responses:
        '200':
          description: New session tokens
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenResponse'
        '400':
          description: Invalid refresh token

  # ─────────────────────────────────────────────
  # User API — /api  (Signal user auth)
  # ─────────────────────────────────────────────

  /api/agent:
    get:
      tags: [user-agent]
      summary: Get agent for current user
      security:
        - SignalUserAuth: []
      responses:
        '200':
          description: Agent info
          content:
            application/json:
              schema:
                type: object
                properties:
                  serviceId:
                    type: string
                  username:
                    type: string
                  container:
                    type: object
        '404':
          description: No agent found

    put:
      tags: [user-agent]
      summary: Create agent for current user
      security:
        - SignalUserAuth: []
      responses:
        '201':
          description: Agent created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentAccount'

    delete:
      tags: [user-agent]
      summary: Delete agent for current user
      security:
        - SignalUserAuth: []
      responses:
        '204':
          description: Agent deleted

  /api/agent/defaultGroup:
    get:
      tags: [user-agent]
      summary: Get default group for current user's agent
      security:
        - SignalUserAuth: []
      responses:
        '200':
          description: Default group
          content:
            application/json:
              schema:
                type: object
                required: [groupId]
                properties:
                  groupId:
                    type: string
        '404':
          description: No default group set

    put:
      tags: [user-agent]
      summary: Set default group for current user's agent
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [groupId]
              properties:
                groupId:
                  type: string
      responses:
        '204':
          description: Default group set
        '400':
          description: Invalid request body

  /api/agent:wake:
    post:
      tags: [user-agent]
      summary: Wake (start) agent for current user
      security:
        - SignalUserAuth: []
      responses:
        '204':
          description: Agent started

  /api/groups/{groupId}/messages/{timestamp}:
    put:
      tags: [user-groups]
      summary: Edit a group message
      security:
        - SignalUserAuth: []
      parameters:
        - name: groupId
          in: path
          required: true
          schema:
            type: string
        - name: timestamp
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: SignalServiceProto.IDataMessage
      responses:
        '202':
          description: Edit accepted

  /api/groups/{groupId}:replay:
    post:
      tags: [user-groups]
      summary: Replay group messages
      security:
        - SignalUserAuth: []
      parameters:
        - name: groupId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [recipientServiceId, fromTimestamp]
              properties:
                recipientServiceId:
                  type: string
                fromTimestamp:
                  type: string
      responses:
        '202':
          description: Replay accepted

  /api/groups/{groupId}:dtmf:
    post:
      tags: [user-groups]
      summary: Send DTMF digits on the active call for a group
      security:
        - SignalUserAuth: []
      parameters:
        - name: groupId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [digits]
              properties:
                digits:
                  type: string
                  description: DTMF digit string (0-9, *, #, A-D)
                durationMs:
                  type: integer
                  description: Duration of each tone in milliseconds
                commandId:
                  type: string
                  description: Optional client-supplied idempotency key
      responses:
        '204':
          description: DTMF sent
        '400':
          description: Invalid request or Telnyx rejected the digits
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: No active call found for this group
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Internal server error

  /api/user/picker-contacts:
    get:
      tags: [user-settings]
      summary: Contact-picker sections for the current user (§7.H)
      description: >
        The caller's pickable user-groups, each with its member individuals, so the
        client can pick either a whole user-group (the section) or a single member.
        Sections: the caller's active org's allUsers group (kind org-all, name = the
        org name); the org subsets the caller is a member of (kind org-subset); and
        the standalone groups the caller created or belongs to (kind standalone,
        orgId null). memberCount is the full count including the caller; members
        EXCLUDES the caller. Empty sections when the caller has no org and no
        standalone groups.
      security:
        - SignalUserAuth: []
      responses:
        '200':
          description: Picker sections
          content:
            application/json:
              schema:
                type: object
                required: [sections]
                properties:
                  sections:
                    type: array
                    items:
                      type: object
                      required: [userGroupId, name, kind, orgId, memberCount, members]
                      properties:
                        userGroupId:
                          type: string
                          format: uuid
                        name:
                          type: string
                        kind:
                          type: string
                          enum: [org-all, org-subset, standalone]
                        orgId:
                          type: [string, 'null']
                          format: uuid
                        memberCount:
                          type: integer
                          description: Total members, including the caller.
                        members:
                          type: array
                          description: Members excluding the caller.
                          items:
                            type: object
                            required: [userId, name, phone, status]
                            properties:
                              userId:
                                type: string
                                format: uuid
                              name:
                                type: string
                              phone:
                                type: string
                              aci:
                                type: string
                              businessNumber:
                                type: string
                                description: >
                                  The member's JBN (Jamb Business Number) to dial for
                                  voice; present when the member has a provisioned
                                  number. For pending (no-ACI) members it is the only
                                  reachable voice target.
                              status:
                                type: string
                                enum: [active, pending]
                                description: >
                                  Per-contact org membership status. 'pending' members
                                  (org-all section) are invited/provisioned but not yet
                                  registered; subset/standalone members are always
                                  'active'.
        '401':
          description: Unauthorized

  /api/user/settings:
    get:
      tags: [user-settings]
      summary: Get settings for current user
      security:
        - SignalUserAuth: []
      responses:
        '200':
          description: User settings (empty object if none saved)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserSettings'

    put:
      tags: [user-settings]
      summary: Update settings for current user
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserSettings'
      responses:
        '204':
          description: Settings saved
        '400':
          description: Invalid request body

  /api/voip:
    get:
      tags: [user-voip]
      summary: Get user's provisioned VOIP phone number
      description: >
        Answers with the number the account holds at the provider this client
        claims to understand. An account may hold one number per provider, so
        omitting the claim is not "any of them" — it is a Telnyx claim, which is
        what every client that has not been updated for a second SIP network can
        actually use.

        An account holding a number only at another provider therefore reads as
        having none, rather than being handed one this client could not dial.
      security:
        - SignalUserAuth: []
      parameters:
        - name: provider
          in: query
          required: false
          description: >
            The provider this client understands. Omit for the default (Telnyx).
          schema:
            $ref: '#/components/schemas/PhoneNumberProvider'
      responses:
        '200':
          description: VOIP phone number
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PhoneNumber'
        '404':
          description: No VOIP number provisioned at the claimed provider

    delete:
      tags: [user-voip]
      summary: Unregister (release) user's VOIP phone number
      description: >
        Deletes the Telnyx phone number and removes the provisioning record for
        the authenticated user. No-ops if the user has no provisioned number.
      security:
        - SignalUserAuth: []
      responses:
        '204':
          description: Phone number unregistered (or was not provisioned)

  /api/voip/lookup/{phoneNumber}:
    get:
      tags: [user-voip]
      summary: Look up carrier info for a phone number
      description: >
        Performs a Telnyx number lookup and enriches the result with carrier
        brand, code family, and unconditional call-forward codes from the
        internal carrier map.
      security:
        - SignalUserAuth: []
      parameters:
        - name: phoneNumber
          in: path
          required: true
          schema:
            type: string
          description: E.164-formatted phone number
        - name: type
          in: query
          required: false
          schema:
            type: string
            enum: [carrier, caller-name]
          description: Limit lookup to carrier or caller-name data only
      responses:
        '200':
          description: Lookup result with carrier info
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NumberLookupResult'

  /api/voip/provisioning/search:
    post:
      tags: [user-voip]
      summary: Search available phone numbers for provisioning
      description: >
        Returns available Telnyx phone numbers filtered by the provided criteria.
        Results can be passed to the reserve endpoint to provision a specific number.
      security:
        - SignalUserAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                provider:
                  $ref: '#/components/schemas/PhoneNumberProvider'
                phone_number:
                  type: object
                  properties:
                    starts_with:
                      type: string
                      description: Filter by phone number prefix (E.164 prefix, e.g. +1617)
      responses:
        '200':
          description: List of available phone numbers
          content:
            application/json:
              schema:
                type: object
                properties:
                  result:
                    type: array
                    items:
                      type: object
                      properties:
                        phoneNumber:
                          type: string
                        location:
                          type: string
                          description: Human-readable region name (city, state, country)
                        provider:
                          $ref: '#/components/schemas/PhoneNumberProvider'
        '422':
          description: >
            The request is well formed and this deployment's phone number
            provider cannot apply part of it — for example a request for a
            consecutive block from a provider that cannot search by block.
            Permanent for this deployment: retrying the same request will not
            succeed. Distinct from 400, which means the body itself was invalid.
        '400':
          description: Invalid request body
        '500':
          description: Upstream Telnyx error

  /api/voip/provisioning/reserve/{phoneNumber}:
    put:
      tags: [user-voip]
      summary: Reserve a specific phone number for the current user
      description: >
        Assigns the given number to the authenticated user. A number already on
        our Telnyx account and not attached to a connection (one previously
        released back to our pool) is attached in place; anything else is
        ordered from Telnyx. Returns 200 if the user already has this number
        provisioned, 201 if it was freshly provisioned, or 403 if Telnyx reports
        the number is no longer available.
      security:
        - SignalUserAuth: []
      parameters:
        - name: phoneNumber
          in: path
          required: true
          schema:
            type: string
          description: E.164-formatted phone number to reserve
        - name: provider
          in: query
          required: false
          description: >
            The provider to reserve through — pass the one the search that
            offered this number ran at. Omit for the default. A number offered
            by one provider and reserved through another is either reported
            unavailable or acquired from a different account.
          schema:
            $ref: '#/components/schemas/PhoneNumberProvider'
      responses:
        '201':
          description: Phone number newly provisioned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PhoneNumber'
        '200':
          description: Phone number already provisioned for this user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PhoneNumber'
        '400':
          description: Invalid phone number format
        '403':
          description: Phone number is not available (rejected by Telnyx)
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    type: string
                    example: Forbidden
                  message:
                    type: string
                    example: Phone number is not available
        '409':
          description: >
            Account already has a different phone number provisioned AT THAT
            PROVIDER (an account may hold one number per provider, so a number
            held elsewhere does not conflict), or the
            requested number is on our account but already attached to a live
            connection (another member's number, an answering service), or it is
            ours but restricted (emergency-only) or porting away — none of which
            change on a retry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Upstream Telnyx error
        '503':
          description: >
            The requested number is ours but not usable yet — a purchase or port
            still settling at the provider, or one that arrived pointing at the
            wrong route. Unlike the 403 and 409 above this is temporary: the
            number has been returned to our pool, and the same request should
            succeed once the provider settles.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /api/voip/external:route:
    post:
      tags: [user-voip]
      summary: Resolve routing info for an external call
      description: >-
        Same behavior as `POST /service/api/voip/external:route`, exposed under
        Signal-user auth. Resolves the routing for a call event via the VoIP
        proxy-contact assignments, in one of three directions: inbound (an
        external party dialed a Jamb business number), outbound (a Jamb user
        dialed an external party via a proxy), or jamb (a Jamb user dialed
        another Jamb user's business number). For inbound and outbound it also
        ensures the relay agent for the external party exists and is running;
        jamb calls bridge user-to-user and have no relay agent.
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [callerIdNumber, callerDestinationNumber]
              properties:
                callerIdNumber:
                  type: string
                  description: The calling party's number (E.164)
                callerDestinationNumber:
                  type: string
                  description: The dialed number (E.164)
      responses:
        '200':
          description: Routing info for the call
          content:
            application/json:
              schema:
                type: object
                required: [kind, aiAgentAci, callerAci, callerPhoneNumber, recipientAci, recipientPhoneNumber, numberToDial, callerIdNumber, jambNumber, provider]
                properties:
                  kind:
                    type: string
                    enum: [inbound, outbound, jamb]
                    description: >-
                      Call direction: inbound (an external party dialed a Jamb
                      business number), outbound (a Jamb user dialed an external
                      party, via a proxy or directly), or jamb (a Jamb user
                      dialed another Jamb user's business number).
                  aiAgentAci:
                    type: string
                    description: ACI of the Jamb user's AI agent (the called user for inbound; the caller for outbound; for jamb, the user whose agent already hosts the two users' shared group, falling back to the lower of the two user ACIs when the pair has no group yet — either way the same agent is selected regardless of call direction)
                  callerAci:
                    type: string
                    description: ACI of the calling party — the relay agent representing the external caller (inbound) or the Jamb user (outbound, jamb)
                  callerPhoneNumber:
                    type: string
                    description: Caller ID presented — the assigned proxy number (inbound) or the caller's business number (outbound, jamb); for a jamb caller with a Jamb account but no business number, a pool proxy stands in for them (their own number is not a Telnyx number)
                  recipientAci:
                    type: string
                    description: ACI of the called party — the Jamb user (inbound, jamb) or the relay agent representing the external party (outbound, including a pending org member represented by their relay agent). Never null — the relay-start block fills the relay agent's ACI for outbound legs
                  recipientPhoneNumber:
                    type: string
                    description: Number the bridge rings — the Jamb user's personal number (inbound, jamb) or the external party (outbound). For jamb it is always the recipient's own personal number (the AI agent reaches them via recipientAci), so it agrees with recipientAci's account
                  numberToDial:
                    type: [string, 'null']
                    description: Telnyx number the caller's device places its outbound PSTN leg to — the recipient's business number for jamb (or, when the recipient has a Jamb account but no business number, the pool proxy assigned to [caller, recipient]); the proxy assigned to [user, external party] for outbound, which is the number the user dialed, or — when they dialed the external party directly and the pair had no assignment yet — the pool proxy assigned to it by this request (except a pending org member's outbound leg, which dials the member's business number directly since it is already a Telnyx number fronting their real phone); always null for inbound
                  callerIdNumber:
                    type: string
                    description: Caller's real phone number
                  jambNumber:
                    type: string
                    description: >-
                      The Jamb-owned number this leg matched on — the one whose
                      provider decides the gateway. The number the call arrived
                      at for inbound, the dial target for jamb, and the caller's
                      business number for outbound. Returned alongside
                      `provider` so the routing decision is auditable: a provider
                      with no number beside it cannot be checked when a call
                      lands on the wrong gateway.
                  provider:
                    type: [string, 'null']
                    description: >-
                      Which network to complete the call on. A carrier generally
                      refuses an ANI you do not own at that carrier, so a leg
                      sent through the wrong gateway is a routing failure.

                      One field is enough because the Jamb numbers on a leg
                      cannot disagree: a proxy is allocated at the same provider
                      as the business number it stands in for, and pair lookups
                      resolve inside one provider.

                      NULL IS AN ORDINARY ANSWER and callers MUST treat it as
                      one — it means "we have no record of where this number came
                      from; use the deployment default", never "refuse the call".
                      A number acquired before the inventory existed has no
                      acquisition record, and those calls route today and must
                      keep routing. A plain string rather than an enum for the
                      same reason the inventory listings use one: the provider
                      set is a code change rather than a migration, so a value
                      this build has never heard of must read back rather than
                      fail validation.
        '404':
          description: The call is unroutable — the dialed number is not a Jamb business number and the caller is not a Jamb user's personal number
        '409':
          description: Every pool proxy is already in use by the Jamb user the proxy link is keyed on — the recipient for inbound/jamb, the caller for outbound (pool exhausted)

  /api/voip/contact-changed:ack:
    post:
      tags: [user-voip]
      summary: Acknowledge an applied external-contact change
      description: >-
        Confirms that the client has applied an `ExternalContactChangedMessage`:
        an external contact the caller reached through the pool proxy
        `oldNumber` is now a Regular User with their own Jamb Business Number
        `newNumber`, and the client's local assignment store (the source of
        truth for dialing) now holds `newNumber`.


        This acknowledgement — not a timer — is what returns the proxy to the
        pool. A superseded assignment keeps routing until it arrives, so a
        client that has not yet synced never loses the ability to call. Send it
        only AFTER the local store commit, and drive it from a durable job
        queue: it must survive process death, backoff, and offline.


        The caller is always the assignment owner (taken from Signal-user auth,
        never from the body), so this can only ever mutate the caller's own
        assignment. Idempotent — a replay of an already-applied change is also
        204.
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [oldNumber, newNumber]
              properties:
                oldNumber:
                  type: string
                  description: The pool proxy the client held for this contact (E.164) — the `oldNumber` of the received message
                newNumber:
                  type: string
                  description: The contact's Jamb Business Number that replaced it (E.164) — the `newNumber` of the received message
      responses:
        '204':
          description: >-
            Acknowledged, or already acknowledged (idempotent replay) —
            including a replay arriving after the acknowledged assignment was
            already released back to the pool. A retry of a successful
            acknowledgement always answers 204, so a lost response never turns a
            success into an apparent failure.
        '400':
          description: '`oldNumber` or `newNumber` is not a valid E.164 number'
        '404':
          description: >-
            No superseded assignment for [caller, `oldNumber`] — an unknown
            proxy, one that was never superseded, a stale notice whose
            `newNumber` no longer matches what the server decided, or one the
            abandonment backstop released without ever hearing an
            acknowledgement. Terminal: the client should stop retrying and wait
            for the next notice.

  /api/voip/{phoneNumber}:
    put:
      tags: [user-voip]
      summary: Register external phone number and get relay agent
      description: >
        Creates a relay agent for the target phone number so the user can call it.
      security:
        - SignalUserAuth: []
      parameters:
        - name: phoneNumber
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Relay agent info
          content:
            application/json:
              schema:
                type: object
                properties:
                  aci:
                    type: string
                  username:
                    type: string

  # ─────────────────────────────────────────────
  # Org API — /api/org  (Signal user auth)
  # ─────────────────────────────────────────────

  /api/org/capabilities:
    get:
      tags: [user-org]
      summary: Org capabilities for the current Signal user
      security:
        - SignalUserAuth: []
      responses:
        '200':
          description: Capability flags for the caller
          content:
            application/json:
              schema:
                type: object
                required: [canCreateOrg, adminOf]
                properties:
                  canCreateOrg:
                    type: boolean
                    description: True when the caller's ACI is on the org-creator allowlist
                  adminOf:
                    type: array
                    description: Ids of orgs where the caller is an active admin
                    items:
                      type: string
                      format: uuid

  /api/org:
    get:
      tags: [user-org]
      summary: List orgs the current Signal user belongs to
      security:
        - SignalUserAuth: []
      responses:
        '200':
          description: The caller's orgs, each with their membership status
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/OrgWithStatus'
    post:
      tags: [user-org]
      summary: Create an org (org-creator allowlist only)
      description: >
        The org id is generated by the server; the client never supplies it.
        The creating caller becomes an active admin of the new org. Any supplied
        members are recorded as pending; members already on Signal (known ACI)
        are then sent an OrgInvitation push best-effort (side-band, never blocks
        or fails this response). Phone-only members are invited once they
        register (see the account-migration webhook).
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  minLength: 1
                members:
                  type: array
                  description: Optional members to record as pending on the new org
                  items:
                    $ref: '#/components/schemas/MemberInput'
      responses:
        '201':
          description: Org created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgResponse'
        '400':
          description: Invalid request body
        '403':
          description: Caller is not on the org-creator allowlist
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    type: string
                    example: Forbidden
                  message:
                    type: string
        '409':
          description: >
            The caller already belongs to an active org (single-org invariant,
            FR-H6). The message body is client-surfaced.
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    type: string
                    example: Conflict
                  message:
                    type: string

  /api/org/{orgId}:
    parameters:
      - name: orgId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      tags: [user-org]
      summary: Get an org and its members (members only)
      description: >
        Each member's `businessPhone` is the number they hold at the provider
        THIS client claims — the claim belongs to the client rendering the
        directory, not to the member being rendered. A member holding a number
        only at another provider surfaces without one, rather than with a number
        this client could not dial. Omitting the claim is a Telnyx claim.
      security:
        - SignalUserAuth: []
      parameters:
        - name: provider
          in: query
          required: false
          description: >
            The provider this client understands. Omit for the default (Telnyx).
          schema:
            $ref: '#/components/schemas/PhoneNumberProvider'
      responses:
        '200':
          description: Org with members
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgWithMembers'
        '403':
          description: Caller is not a member of this org
        '404':
          description: Org not found
    patch:
      tags: [user-org]
      summary: Rename an org (admin only)
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  minLength: 1
      responses:
        '200':
          description: Org renamed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgResponse'
        '400':
          description: Invalid request body
        '403':
          description: Caller is not an admin of this org
        '404':
          description: Org not found
    delete:
      tags: [user-org]
      summary: Soft-delete an org (admin only)
      description: Refused when the org still has other active members.
      security:
        - SignalUserAuth: []
      responses:
        '204':
          description: Org soft-deleted
        '403':
          description: Caller is not an admin, or the org still has other active members
        '404':
          description: Org not found

  /api/org/{orgId}/provisioning/search:
    parameters:
      - name: orgId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      tags: [user-org]
      summary: Number picker — search available Telnyx numbers (org member, read-only)
      description: >
        Read-only variant of the POST search, for the Front Desk "Receptionist
        Number" picker (§7.G). Side-effect-free. The caller must be a member of the org.
      security:
        - SignalUserAuth: []
      parameters:
        - name: provider
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/PhoneNumberProvider'
        - name: startsWith
          in: query
          required: false
          schema:
            type: string
            pattern: '^\d+$'
          description: Filter available numbers by digit prefix (e.g. an area code)
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 50
          description: Maximum number of phone numbers to return
      responses:
        '200':
          description: List of available phone numbers
          content:
            application/json:
              schema:
                type: object
                required: [result]
                properties:
                  result:
                    type: array
                    items:
                      type: object
                      required: [phoneNumber, location, provider]
                      properties:
                        provider:
                          $ref: '#/components/schemas/PhoneNumberProvider'
                        phoneNumber:
                          type: string
                        location:
                          type: string
                          description: Human-readable region name (city, state, country)
        '422':
          description: >
            The request is well formed and this deployment's phone number
            provider cannot apply part of it — for example a request for a
            consecutive block from a provider that cannot search by block.
            Permanent for this deployment: retrying the same request will not
            succeed. Distinct from 400, which means the body itself was invalid.
        '400':
          description: Invalid query
        '401':
          description: Unauthorized
        '403':
          description: Forbidden (not a member of this org)
        '404':
          description: Org not found
        '500':
          description: Internal server error or upstream Telnyx error
    post:
      tags: [user-org]
      summary: Search available Telnyx numbers for provisioning (org member)
      description: >
        Signal-authed mirror of POST /org/orgs/{orgId}/provisioning/search.
        The caller must be a member of the org.
      security:
        - SignalUserAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                provider:
                  $ref: '#/components/schemas/PhoneNumberProvider'
                phone_number:
                  type: object
                  properties:
                    starts_with:
                      type: string
                      description: Filter by phone number prefix (E.164 prefix, e.g. +1617)
                consecutive:
                  type: integer
                  minimum: 1
                  description: Number of consecutive phone numbers to return
                limit:
                  type: integer
                  minimum: 1
                  description: Maximum number of phone numbers to return
      responses:
        '200':
          description: List of available phone numbers
          content:
            application/json:
              schema:
                type: object
                required: [result]
                properties:
                  result:
                    type: array
                    items:
                      type: object
                      required: [phoneNumber, location, provider]
                      properties:
                        provider:
                          $ref: '#/components/schemas/PhoneNumberProvider'
                        phoneNumber:
                          type: string
                        location:
                          type: string
                          description: Human-readable region name (city, state, country)
        '422':
          description: >
            The request is well formed and this deployment's phone number
            provider cannot apply part of it — for example a request for a
            consecutive block from a provider that cannot search by block.
            Permanent for this deployment: retrying the same request will not
            succeed. Distinct from 400, which means the body itself was invalid.
        '400':
          description: Invalid request body
        '401':
          description: Unauthorized
        '403':
          description: Forbidden (not a member of this org)
        '404':
          description: Org not found
        '500':
          description: Internal server error or upstream Telnyx error

  /api/org/{orgId}/provisioning/availablePhoneNumbers:
    parameters:
      - name: orgId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      tags: [user-org]
      summary: List unassigned Telnyx numbers on the account (org member)
      description: >
        Signal-authed mirror of POST
        /org/orgs/{orgId}/provisioning/availablePhoneNumbers. Lists numbers
        already on the Telnyx account that are not yet assigned to a connection.
        The caller must be a member of the org.
      security:
        - SignalUserAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                provider:
                  $ref: '#/components/schemas/PhoneNumberProvider'
                phone_number:
                  type: object
                  properties:
                    starts_with:
                      type: string
                      description: Filter results to numbers starting with this prefix
      responses:
        '200':
          description: List of available phone numbers with location
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required: [phoneNumber, location, provider]
                  properties:
                    phoneNumber:
                      type: string
                      description: E.164 phone number
                    location:
                      type: string
                      description: Human-readable region name (city, state, country)
                    provider:
                      $ref: '#/components/schemas/PhoneNumberProvider'
        '401':
          description: Unauthorized
        '403':
          description: Forbidden (not a member of this org)
        '404':
          description: Org not found
        '500':
          description: Internal server error or upstream Telnyx error

  /api/org/{orgId}/provisioning/phoneNumbers:
    parameters:
      - name: orgId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      tags: [user-org]
      summary: Provision Telnyx numbers to org members (active admin only)
      description: >
        Signal-authed mirror of POST
        /org/orgs/{orgId}/provisioning/phoneNumbers. The caller must be an
        active admin of the org, and must be an admin of a shared org with each
        target user. Each target user must resolve to a Jamb service id.
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phoneNumbers]
              properties:
                provider:
                  $ref: '#/components/schemas/PhoneNumberProvider'
                phoneNumbers:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [userId, phone]
                    properties:
                      userId:
                        type: string
                        format: uuid
                        description: Org user ID to associate with the provisioned number
                      phone:
                        type: string
                        description: E.164 phone number to provision
                overrideExisting:
                  type: boolean
                  default: false
                  description: When true, replaces any existing phone number registration for a user instead of erroring
      responses:
        '200':
          description: All phone numbers were successfully provisioned on Telnyx
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required: [userId, phoneNumber]
                  properties:
                    userId:
                      type: string
                      format: uuid
                    phoneNumber:
                      type: string
                      description: Provisioned E.164 phone number
        '400':
          description: Invalid phone number (E.164 required), unknown userId, or a target user with no Jamb service id
        '401':
          description: Unauthorized
        '403':
          description: Caller is not an active admin of this org, or not an admin of any shared org with a target user, or phone number unavailable on Telnyx
        '404':
          description: Org not found
        '500':
          description: Internal server error or upstream Telnyx error
        '503':
          description: >
            One or more of the requested numbers is ours but not usable yet — a
            purchase or port still settling at the provider, or one that arrived
            pointing at the wrong route. Unlike the 403 above this is temporary:
            such numbers are returned to our pool, and the same request should
            succeed once the provider settles. Returned only when EVERY blocker
            is of this kind; a batch that also contains a permanently unusable
            number is a 409 or 403 instead, because retrying it cannot succeed.

  /api/org/{orgId}/provisioning/bulk:
    parameters:
      - name: orgId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      tags: [user-org]
      summary: Bulk-provision a consecutive block of Telnyx numbers to org members (active admin only)
      description: >
        One-shot bulk provisioning. The caller must be an active admin of the
        org and an admin of a shared org with each target user. Idempotent:
        members who already hold a Jamb business number are skipped and their
        existing number is returned unchanged (no new Telnyx order, no billing).
        For the remaining members the server picks a consecutive block sized to
        that needy set at a target prefix (`startsWith` if supplied, otherwise
        the admin's own registered area code) and assigns the block to them. By
        default (`softMatch` false) the search uses ONLY that target prefix — a
        single tier with NO automatic widening; if no consecutive block of the
        needed size exists there the call fails so the caller can retry with a
        different prefix. Pass `softMatch: true` to opt into progressive
        (tightest-first) relaxation: target prefix, then its 3-digit area code
        (when the target is an NPA-NXX), then an unconstrained consecutive
        search. Registered members bind to their Jamb service id; pending
        members with no Jamb account yet bind to their org-member phone with a
        null service id. When every requested member already has a number, no
        Telnyx search or order is made. The response lists every requested
        member with their number (existing or newly provisioned), so repeating
        the call with the same userIds is a no-op that returns the same numbers.
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userIds]
              properties:
                provider:
                  $ref: '#/components/schemas/PhoneNumberProvider'
                userIds:
                  type: array
                  minItems: 1
                  maxItems: 20
                  description: >
                    Org user IDs to provision (deduped); one number per id.
                    Capped at 20 per call.
                  items:
                    type: string
                    format: uuid
                startsWith:
                  type: string
                  pattern: '^\d+$'
                  description: >
                    Digits only — the NPA (3-digit) or NPA-NXX (6-digit) prefix
                    to search near. Defaults to the admin's own area code when
                    omitted. Required (with softMatch false) if the admin's own
                    number has no derivable area code.
                softMatch:
                  type: boolean
                  default: false
                  description: >
                    When false (default) the block is searched only at the
                    target prefix — no automatic widening. When true, widen
                    progressively (target prefix, then its NPA, then
                    unconstrained) until a consecutive block is found.
      responses:
        '200':
          description: A number was provisioned for every requested user
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required: [userId, phoneNumber]
                  properties:
                    userId:
                      type: string
                      format: uuid
                    phoneNumber:
                      type: string
                      description: Provisioned E.164 phone number
        '422':
          description: >
            The request is well formed and this deployment's phone number
            provider cannot apply part of it — for example a request for a
            consecutive block from a provider that cannot search by block.
            Permanent for this deployment: retrying the same request will not
            succeed. Distinct from 400, which means the body itself was invalid.
        '400':
          description: >
            Invalid request body, an unknown userId, more than 20 userIds, or
            softMatch is false with no target prefix (no startsWith and the
            admin number has no derivable area code)
        '401':
          description: Unauthorized
        '403':
          description: >
            Caller is not an active admin of this org, not an admin of any shared
            org with a target user, or no consecutive block of numbers is
            available on Telnyx at the target prefix (softMatch false) or after
            widening (softMatch true)
        '404':
          description: Org not found
        '500':
          description: Internal server error or upstream Telnyx error
        '503':
          description: >
            One or more of the requested numbers is ours but not usable yet — a
            purchase or port still settling at the provider, or one that arrived
            pointing at the wrong route. Unlike the 403 above this is temporary:
            such numbers are returned to our pool, and the same request should
            succeed once the provider settles. Returned only when EVERY blocker
            is of this kind; a batch that also contains a permanently unusable
            number is a 409 or 403 instead, because retrying it cannot succeed.

  /api/org/{orgId}/members:
    parameters:
      - name: orgId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      tags: [user-org]
      summary: Add members to an org (active admin only)
      description: >
        Each member is recorded as pending. Adding an identity that is already an
        active/pending member is idempotent; re-adding a previously removed
        member reactivates them as pending. Members that end up pending and are
        already on Signal (known ACI) are sent an OrgInvitation push best-effort
        (side-band; already-active members and phone-only members are not).
        Phone-only members are invited once they register.
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [members]
              properties:
                members:
                  type: array
                  minItems: 1
                  items:
                    $ref: '#/components/schemas/MemberInput'
      responses:
        '201':
          description: Members added; returns the org's current member list
          content:
            application/json:
              schema:
                type: object
                required: [members]
                properties:
                  members:
                    type: array
                    items:
                      $ref: '#/components/schemas/OrgMember'
        '400':
          description: Invalid request body (e.g. a member entry with neither aci nor phone)
        '403':
          description: Caller is not an active admin of this org
        '404':
          description: Org not found

  /api/org/{orgId}/members/@/accept:
    parameters:
      - name: orgId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      tags: [user-org]
      summary: Accept your own pending invitation
      description: >
        The caller's pending membership becomes active. If they were invited by
        phone, their Signal ACI is backfilled onto their user record on accept.
        Single-org invariant (FR-H6): if the caller is already active in another
        org, accepting auto-leaves it first — unless they are that org's last
        active admin, in which case the accept is rejected with 409.
      security:
        - SignalUserAuth: []
      responses:
        '200':
          description: Membership activated; returns the caller's member projection
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgMember'
        '403':
          description: Caller has no pending invitation for this org
        '404':
          description: Org not found
        '409':
          description: >
            The caller already belongs to another active org and is its last
            active admin; they must leave or transfer that org before joining
            another. The message body is client-surfaced.
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    type: string
                    example: Conflict
                  message:
                    type: string

  /api/org/{orgId}/members/@/decline:
    parameters:
      - name: orgId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      tags: [user-org]
      summary: Decline your own pending invitation
      description: The caller's pending membership is soft-deleted.
      security:
        - SignalUserAuth: []
      responses:
        '204':
          description: Invitation declined
        '403':
          description: Caller has no pending invitation for this org
        '404':
          description: Org not found

  /api/org/{orgId}/members/@:
    parameters:
      - name: orgId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    delete:
      tags: [user-org]
      summary: Leave an org
      description: >
        The caller's membership is soft-deleted. The last active admin may not
        leave (must transfer adminship or delete the org first).
      security:
        - SignalUserAuth: []
      responses:
        '204':
          description: Left the org
        '403':
          description: Caller is not a member, or is the org's last active admin
        '404':
          description: Org not found

  /api/org/{orgId}/members/{userId}:
    parameters:
      - name: orgId
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: userId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    patch:
      tags: [user-org]
      summary: Update a member's role/name/title/department/replayHistoryOnJoin (active admin only)
      description: >
        At least one of role/name/title/department/replayHistoryOnJoin is
        required. `name` sets a
        per-org display name (org_members.name) that overrides the global
        users.name in this org's directory, without renaming the person
        globally. Demoting the last active admin to member is refused.
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                role:
                  type: string
                  enum: [admin, member]
                name:
                  type: string
                  minLength: 1
                  description: >
                    Per-org display name (org_members.name); overrides the
                    global users.name in this org's directory.
                title:
                  type: string
                department:
                  type: string
                replayHistoryOnJoin:
                  type: boolean
                  description: >
                    Per-member flag: when true, the owner agent replays
                    org-group history to this member on join (spec B3b).
      responses:
        '200':
          description: Member updated; returns the member projection
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgMember'
        '400':
          description: Invalid request body (no fields to update)
        '403':
          description: Caller is not an active admin, or would demote the last active admin
        '404':
          description: Org or user not found
    delete:
      tags: [user-org]
      summary: Remove a member (active admin only)
      description: >
        Soft-deletes the membership. Removing the last active admin is refused;
        removing a pending invitee (rescind) is allowed.
      security:
        - SignalUserAuth: []
      responses:
        '204':
          description: Member removed
        '403':
          description: Caller is not an active admin, or would remove the last active admin
        '404':
          description: Org not found

  # ─────────────────────────────────────────────
  # User Groups — /api/user-groups  (Signal user auth, §7.H)
  # The reusable membership/ACL primitive. Phase-1 authz is creator-owned:
  # writes require created_by == caller; reads allow the creator or a member.
  # ─────────────────────────────────────────────

  /api/user-groups:
    get:
      tags: [user-groups-primitive]
      summary: Reverse lookup — groups the caller created that reference an entity
      description: >
        Returns the caller-created user groups referencing the given
        (entityType, entityId). Phase-1 authz is creator-scoped; entity-level
        authz arrives with the entity phases.
      security:
        - SignalUserAuth: []
      parameters:
        - name: entityType
          in: query
          required: true
          schema:
            type: string
            enum: [org, project, task]
        - name: entityId
          in: query
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The caller's groups referencing the entity
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/GroupView'
        '400':
          description: Invalid or missing entityType/entityId
        '403':
          description: Caller has no user profile
    post:
      tags: [user-groups-primitive]
      summary: Create a standalone user group
      description: >
        Creates a group with created_by = caller and org_id = null, plus an
        optional initial member set.
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  minLength: 1
                members:
                  type: array
                  description: Optional initial member user ids
                  items:
                    type: string
                    format: uuid
      responses:
        '201':
          description: Group created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GroupView'
        '400':
          description: Invalid request body

  /api/user-groups/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      tags: [user-groups-primitive]
      summary: Get a user group and its members (creator or member)
      security:
        - SignalUserAuth: []
      responses:
        '200':
          description: Group with members
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GroupView'
        '403':
          description: Caller is not the creator or a member of this group
        '404':
          description: Group not found
    patch:
      tags: [user-groups-primitive]
      summary: Rename a user group (creator only)
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  minLength: 1
      responses:
        '200':
          description: Group renamed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GroupView'
        '400':
          description: Invalid request body
        '403':
          description: Caller is not the creator of this group
        '404':
          description: Group not found
    delete:
      tags: [user-groups-primitive]
      summary: Soft-delete a user group (creator only)
      security:
        - SignalUserAuth: []
      responses:
        '204':
          description: Group soft-deleted
        '403':
          description: Caller is not the creator of this group
        '404':
          description: Group not found

  /api/user-groups/{id}/members:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      tags: [user-groups-primitive]
      summary: List a group's members (creator or member)
      security:
        - SignalUserAuth: []
      responses:
        '200':
          description: The group's members
          content:
            application/json:
              schema:
                type: object
                required: [members]
                properties:
                  members:
                    type: array
                    items:
                      $ref: '#/components/schemas/UserGroupMemberRef'
        '403':
          description: Caller is not the creator or a member of this group
        '404':
          description: Group not found
    post:
      tags: [user-groups-primitive]
      summary: Add a member to a group (creator only, idempotent)
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId]
              properties:
                userId:
                  type: string
                  format: uuid
      responses:
        '201':
          description: Member added; returns the group's members
          content:
            application/json:
              schema:
                type: object
                required: [members]
                properties:
                  members:
                    type: array
                    items:
                      $ref: '#/components/schemas/UserGroupMemberRef'
        '400':
          description: Invalid request body
        '403':
          description: Caller is not the creator of this group
        '404':
          description: Group not found

  /api/user-groups/{id}/members/{userId}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: userId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    delete:
      tags: [user-groups-primitive]
      summary: Remove a member from a group (creator only)
      security:
        - SignalUserAuth: []
      responses:
        '204':
          description: Member removed
        '403':
          description: Caller is not the creator of this group
        '404':
          description: Group not found

  /api/user-groups/{id}/references:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      tags: [user-groups-primitive]
      summary: Reference an entity from a group (creator only, idempotent)
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [entityType, entityId]
              properties:
                entityType:
                  type: string
                  enum: [org, project, task]
                entityId:
                  type: string
                  format: uuid
      responses:
        '201':
          description: Reference created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserGroupReference'
        '400':
          description: Invalid request body (unknown entityType, etc.)
        '403':
          description: Caller is not the creator of this group
        '404':
          description: Group not found

  /api/user-groups/{id}/references/{entityType}/{entityId}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: entityType
        in: path
        required: true
        schema:
          type: string
          enum: [org, project, task]
      - name: entityId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    delete:
      tags: [user-groups-primitive]
      summary: Remove a group→entity reference (creator only)
      security:
        - SignalUserAuth: []
      responses:
        '204':
          description: Reference removed
        '400':
          description: Invalid entityType/entityId
        '403':
          description: Caller is not the creator of this group
        '404':
          description: Group not found

  # ─────────────────────────────────────────────
  # Org subset groups — /api/org/{orgId}/user-groups  (Signal user auth, §7.H)
  # Org-bound user groups whose members must be a subset of the org's allUsers
  # set (its active members). Writes require an active org admin; reads require an
  # org member. The allUsers group is not editable through this surface.
  # ─────────────────────────────────────────────

  /api/org/{orgId}/user-groups:
    parameters:
      - name: orgId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      tags: [user-groups-primitive]
      summary: List the org's subset groups (org member)
      description: >
        Returns the org's subset groups (org_id = orgId, is_org_all = false),
        excluding the allUsers group.
      security:
        - SignalUserAuth: []
      responses:
        '200':
          description: The org's subset groups
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/GroupView'
        '403':
          description: Caller is not a member of this org
    post:
      tags: [user-groups-primitive]
      summary: Create an org subset group (active admin)
      description: >
        Creates a subset group (org_id = orgId, is_org_all = false,
        created_by = caller). The org must have an allUsers group with at least
        one active member, and every requested member must be in that allUsers set.
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  minLength: 1
                members:
                  type: array
                  description: Optional initial member user ids (must be ⊆ allUsers)
                  items:
                    type: string
                    format: uuid
      responses:
        '201':
          description: Subset group created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GroupView'
        '400':
          description: Org has no members, or a member is not in the org's allUsers set
        '403':
          description: Caller is not an active admin of this org

  /api/org/{orgId}/user-groups/{groupId}:
    parameters:
      - name: orgId
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: groupId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      tags: [user-groups-primitive]
      summary: Get an org subset group (org member)
      security:
        - SignalUserAuth: []
      responses:
        '200':
          description: The subset group
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GroupView'
        '403':
          description: Caller is not a member of this org
        '404':
          description: Group not found in this org (or is the allUsers group)
    patch:
      tags: [user-groups-primitive]
      summary: Rename an org subset group (active admin)
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  minLength: 1
      responses:
        '200':
          description: Group renamed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GroupView'
        '400':
          description: Invalid request body
        '403':
          description: Caller is not an active admin of this org
        '404':
          description: Group not found in this org (or is the allUsers group)
    delete:
      tags: [user-groups-primitive]
      summary: Soft-delete an org subset group (active admin)
      security:
        - SignalUserAuth: []
      responses:
        '204':
          description: Group soft-deleted
        '403':
          description: Caller is not an active admin of this org
        '404':
          description: Group not found in this org (or is the allUsers group)

  /api/org/{orgId}/user-groups/{groupId}/members:
    parameters:
      - name: orgId
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: groupId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      tags: [user-groups-primitive]
      summary: Add a member to an org subset group (active admin)
      description: >
        Adds a member; 400 unless the user is in the org's allUsers set
        (⊆ allUsers). Idempotent.
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId]
              properties:
                userId:
                  type: string
                  format: uuid
      responses:
        '201':
          description: Member added; returns the group's members
          content:
            application/json:
              schema:
                type: object
                required: [members]
                properties:
                  members:
                    type: array
                    items:
                      $ref: '#/components/schemas/UserGroupMemberRef'
        '400':
          description: User is not in the org's allUsers set
        '403':
          description: Caller is not an active admin of this org
        '404':
          description: Group not found in this org (or is the allUsers group)

  /api/org/{orgId}/user-groups/{groupId}/members/{userId}:
    parameters:
      - name: orgId
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: groupId
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: userId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    delete:
      tags: [user-groups-primitive]
      summary: Remove a member from an org subset group (active admin)
      security:
        - SignalUserAuth: []
      responses:
        '204':
          description: Member removed
        '403':
          description: Caller is not an active admin of this org
        '404':
          description: Group not found in this org (or is the allUsers group)

  # ─────────────────────────────────────────────
  # Answering services — /api/answering-services  (Signal user auth)
  #
  # Every endpoint is 403 unless the caller is BOTH on the org-creator allowlist
  # AND an active admin of their single active org. The org is resolved
  # server-side, never taken from the client, and a service belonging to another
  # org is reported as 404 rather than 403.
  # ─────────────────────────────────────────────

  /api/answering-services:
    get:
      tags: [user-answering-services]
      summary: List the org's answering services
      description: The caller's org's live answering services, newest first.
      security:
        - SignalUserAuth: []
      parameters:
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: count
          in: query
          required: false
          description: Page size; capped at 200 to match the AI server's instruction-list cap
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        '200':
          description: The org's answering services (an empty array when it has none)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/AnsweringService'
        '400':
          description: Invalid offset/count
        '403':
          description: Caller is not allow-listed, or not an active admin of an org
    post:
      tags: [user-answering-services]
      summary: Create an answering service
      description: >
        Provisions the phone number from Telnyx, creates the AI-server instruction
        for it (company name, the user group's members as the reachable team, and
        the recording disclosure), then records the service.
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateAnsweringService'
      responses:
        '201':
          description: Answering service created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnsweringService'
        '400':
          description: Invalid body, or the user group is not a live group of this org
        '403':
          description: Caller is not allow-listed, or not an active admin of an org
        '409':
          description: The phone number is unavailable, or already in use
        '503':
          description: Telnyx or the AI server failed; nothing was persisted

  /api/answering-services/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Opaque service identifier, as returned by create and list
        schema:
          type: string
          minLength: 1
    get:
      tags: [user-answering-services]
      summary: Get an answering service
      security:
        - SignalUserAuth: []
      responses:
        '200':
          description: The answering service
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnsweringService'
        '403':
          description: Caller is not allow-listed, or not an active admin of an org
        '404':
          description: No such answering service in the caller's org
    put:
      tags: [user-answering-services]
      summary: Update an answering service
      description: >
        Shallow partial update. Changing the number provisions the new one,
        re-points the instruction, then releases the old one; changing the user
        group re-points the AI's team.
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateAnsweringService'
      responses:
        '200':
          description: The updated answering service
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnsweringService'
        '400':
          description: Invalid body, or the user group is not a live group of this org
        '403':
          description: Caller is not allow-listed, or not an active admin of an org
        '404':
          description: No such answering service in the caller's org
        '409':
          description: >
            The new phone number is unavailable or already in use, or the service
            was modified concurrently (this update was decided from row state
            another writer has since changed) — retry
        '503':
          description: Telnyx or the AI server failed; the service is unchanged
    delete:
      tags: [user-answering-services]
      summary: Delete an answering service
      description: >
        Soft-deletes the service, then best-effort deletes the AI-server
        instruction and releases the Telnyx number. A failure in either external
        system is logged and does not fail the request.
      security:
        - SignalUserAuth: []
      responses:
        '204':
          description: Answering service deleted
        '403':
          description: Caller is not allow-listed, or not an active admin of an org
        '404':
          description: >
            No such answering service in the caller's org — including the loser of
            two concurrent deletes, since the winner owns the teardown

  # ─────────────────────────────────────────────
  # Org-linked groups — /api/group  (Signal user auth, §7.C)
  # ─────────────────────────────────────────────

  /api/group/{groupId}/user-groups:
    parameters:
      - name: groupId
        in: path
        required: true
        description: Base64url Signal group id
        schema:
          type: string
          minLength: 1
    get:
      tags: [user-group]
      summary: List the user-groups a group is tracking
      description: >
        Returns the user-groups this Signal group tracks, each with its live member
        count and owning-org context, for the tracking UX (§7.H, B1).
      security:
        - SignalUserAuth: []
      responses:
        '200':
          description: The group's tracked user-groups
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TrackedUserGroup'
    post:
      tags: [user-group]
      summary: Link a user-group to a group
      description: >
        Links a user-group to this Signal group so the group tracks the user-group's
        members (§7.H, B2). Authz (B6/B7): an ORG-BOUND user-group (its org's allUsers
        roster or a subset group) requires the caller to be an ACTIVE admin of that
        org; a STANDALONE user-group requires the caller to be its creator. Linking
        designates the caller's own agent as the group's propagator. It never rejects
        on overlap: a current group member who is also a member of the user-group is
        absorbed as derived by the reconcile (no duplicate check, no 409). Returns the
        group's updated tracked user-groups list; a reconcile is dispatched side-band.
      security:
        - SignalUserAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userGroupId]
              properties:
                userGroupId:
                  type: string
                  format: uuid
      responses:
        '200':
          description: User-group linked; returns the tracked user-groups list
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TrackedUserGroup'
        '400':
          description: Invalid request body
        '403':
          description: Caller is not entitled to link the user-group
        '404':
          description: User-group not found

  /api/group/{groupId}/user-groups/{userGroupId}:
    parameters:
      - name: groupId
        in: path
        required: true
        description: Base64url Signal group id
        schema:
          type: string
          minLength: 1
      - name: userGroupId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    delete:
      tags: [user-group]
      summary: Unlink a user-group from a group
      description: >
        Stops tracking the user-group and soft-deletes the link (§7.H, B3). Authz
        (B6/B7): same entitlement bar as linking — active org admin for an org-bound
        user-group, creator for a standalone one. Returns the group's updated tracked
        user-groups list; a reconcile that removes now-unjustified derived members is
        dispatched side-band.
      security:
        - SignalUserAuth: []
      responses:
        '200':
          description: User-group unlinked; returns the tracked user-groups list
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TrackedUserGroup'
        '403':
          description: Caller is not entitled to unlink the user-group
        '404':
          description: User-group not found
        '409':
          description: >-
            Cannot unlink an org's dedicated group from its own allUsers roster —
            the dedicated group must always track its org (unlinking any OTHER group
            from the allUsers roster is allowed).

  /api/group/{groupId}/resync:
    parameters:
      - name: groupId
        in: path
        required: true
        description: Base64url Signal group id
        schema:
          type: string
          minLength: 1
    post:
      tags: [user-group]
      summary: Trigger an on-demand re-sync of a group against its tracked user-groups
      description: >
        Requests an on-demand reconcile of the group against the current union of its
        tracked user-groups' members (§7.H, B4). Authz (B9): the caller must be
        entitled to at least one user-group the group currently tracks — an active
        admin of any tracked org-bound user-group's org, or the creator of any tracked
        standalone user-group. The reconcile runs side-band.
      security:
        - SignalUserAuth: []
      responses:
        '202':
          description: Re-sync accepted; the reconcile runs side-band
          content:
            application/json:
              schema:
                type: object
                required: [groupId]
                properties:
                  groupId:
                    type: string
        '403':
          description: Caller is not entitled to any user-group this group tracks

  # ─────────────────────────────────────────────
  # Admin API — /admin/api  (Supabase JWT auth)
  # ─────────────────────────────────────────────

  /admin/api/me:
    get:
      tags: [admin-misc]
      summary: Get current admin user info
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Admin user object
          content:
            application/json:
              schema:
                type: object

    delete:
      tags: [admin-misc]
      summary: Delete admin user's Signal account
      security:
        - BearerAuth: []
      responses:
        '204':
          description: Account deleted

  /admin/api/me/rate-limits:
    get:
      tags: [admin-misc]
      summary: Get rate limits for current admin user
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Rate limits
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/RateLimit'

  /admin/api/me/rate-limits/{key}:
    delete:
      tags: [admin-misc]
      summary: Clear a specific rate limit key for current user
      security:
        - BearerAuth: []
      parameters:
        - name: key
          in: path
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Rate limit cleared

  /admin/api/accounts:
    get:
      tags: [admin-accounts]
      summary: List all Signal accounts with pagination
      security:
        - BearerAuth: []
      parameters:
        - name: max
          in: query
          schema:
            type: integer
        - name: continuationToken
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Paginated account list
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/SignalAccount'
                  continuationToken:
                    type: string

  /admin/api/accounts/{uuid}:
    get:
      tags: [admin-accounts]
      summary: Get Signal account by ACI
      security:
        - BearerAuth: []
      parameters:
        - name: uuid
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Account info
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SignalAccount'
        '404':
          description: Not found

  /admin/api/accounts/{uuid}/orgAdmin:
    put:
      tags: [admin-accounts]
      summary: Grant or revoke an account's org-admin capability
      description: >
        Sets whether the account (by ACI) is on the org-creator allowlist.
        Enabling is idempotent and reactivates a previously-revoked entry;
        disabling soft-deletes the active entry. Returns the resulting state.
      security:
        - BearerAuth: []
      parameters:
        - name: uuid
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Account ACI
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled:
                  type: boolean
                  description: Whether the account should hold the org-admin capability.
      responses:
        '200':
          description: Resulting org-admin state
          content:
            application/json:
              schema:
                type: object
                required: [isOrgAdmin]
                properties:
                  isOrgAdmin:
                    type: boolean
        '400':
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /admin/api/accounts/{uuid}/reserve/{phoneNumber}:
    put:
      tags: [admin-accounts]
      summary: Reserve a phone number for a Signal account
      description: >
        Assigns the given number to the specified account (by ACI). A number
        already on our account at the chosen provider and held by nothing (one
        previously released back to our pool) is attached in place; anything else
        is ordered from that provider. Returns 200 if the account already has this
        number provisioned, 201 if it was freshly provisioned, or 403 if the
        provider reports the number is no longer available.


        Pass the `provider` the search that offered this number ran at. A number
        offered by one provider and reserved through another is either reported
        unavailable — the second never had it to sell — or acquired from a
        different account.
      security:
        - BearerAuth: []
      parameters:
        - name: uuid
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Account ACI
        - name: phoneNumber
          in: path
          required: true
          schema:
            type: string
          description: E.164-formatted phone number to reserve
        - name: provider
          in: query
          required: false
          description: >
            The provider to reserve through — pass the one the search that
            offered this number ran at. Omit for the default.
          schema:
            $ref: '#/components/schemas/PhoneNumberProvider'
      responses:
        '200':
          description: >
            Phone number already provisioned for this account. No `provider` is
            reported: the number was acquired by an earlier request, which may
            have run at a different provider, and this response would otherwise
            state a provenance it did not check. Use
            GET /admin/api/phoneNumbers/inventory/{phoneNumber} for that.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PhoneNumber'
        '201':
          description: Phone number newly provisioned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReservedPhoneNumber'
        '400':
          description: Invalid phone number format
        '403':
          description: Phone number is not available (rejected by Telnyx)
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    type: string
                    example: Forbidden
                  message:
                    type: string
                    example: Phone number is not available
        '409':
          description: >
            Account already has a different phone number provisioned AT THAT
            PROVIDER (an account may hold one number per provider, so a number
            held elsewhere does not conflict), or the
            requested number is on our account but already attached to a live
            connection (another member's number, an answering service), or it is
            ours but restricted (emergency-only) or porting away — none of which
            change on a retry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: >
            The `provider` named is one this build knows but this deployment is
            not configured to acquire from — its credentials or its routing
            target are unset. Raised before anything is acquired. Permanent for
            this deployment: retrying the same request will not succeed. Distinct
            from 400, which means the provider name itself was not recognised.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: >
            The requested number is ours but not usable yet — a purchase or port
            still settling at the provider, or one that arrived pointing at the
            wrong route. Unlike the 403 and 409 above this is temporary: the
            number has been returned to our pool, and the same request should
            succeed once the provider settles.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /admin/api/phoneNumbers/registrations:
    get:
      tags: [admin-phone]
      summary: List VOIP registrations (admin)
      description: >
        The `voip_users` pairings — each a business number bound to the personal
        number that actually rings. An account may hold a number at more than one
        provider, and this is the only listing that shows all of them: the
        account listing reports one number per account however many it has, the
        inventory lists acquisitions rather than pairings, and the proxy routes
        list proxies.

        Each row carries BOTH provenances. `recordedProvider` is stamped once at
        provisioning and never revised; `effectiveProvider` follows the live
        acquisition record and is what the routing queries resolve. They differ
        for a ported number, and seeing the difference is the point — a listing
        that reported only the column would disagree with the call being
        debugged.

        Paged by an opaque cursor over `(businessNumber, id)`. `total` counts the
        filter without the cursor, so it answers "how many are there" rather than
        "how many are left"; it may move between requests, and is not promised
        not to.
      security:
        - BearerAuth: []
      parameters:
        - name: provider
          in: query
          required: false
          description: >
            One provider or several, comma-separated — registrations at ANY of
            them, compared on the EFFECTIVE provider. An unrecognized name is
            rejected rather than ignored: a filter that looks applied and is not
            is worse than an error.
          schema:
            type: string
            example: telnyx,carrierx
        - name: aci
          in: query
          required: false
          description: Everything one owner holds, by ACI.
          schema:
            type: string
        - name: personalNumber
          in: query
          required: false
          description: >
            Everything one owner holds, by the number that rings. The only key
            that reaches a PENDING org member, who holds a JBN with no ACI.
          schema:
            type: string
        - name: showDeleted
          in: query
          required: false
          description: >
            Include given-up registrations. Off by default; they are what to look
            at when a number seems stranded — the inventory still naming an owner
            whose row is gone.
          schema:
            type: boolean
            default: false
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
        - name: cursor
          in: query
          required: false
          description: A page boundary from a previous `nextCursor`.
          schema:
            type: string
      responses:
        '200':
          description: One page of registrations
          content:
            application/json:
              schema:
                type: object
                properties:
                  result:
                    type: array
                    items:
                      $ref: '#/components/schemas/VoipRegistrationRow'
                  total:
                    type: integer
                    description: How many match the filter, ignoring the cursor
                  nextCursor:
                    type: [string, 'null']
                    description: Absent when this is the last page
        '400':
          description: Unrecognized provider, malformed cursor, or out-of-range limit
        '401':
          description: Missing or invalid admin credentials

  /admin/api/phoneNumbers/registrations/{id}:
    get:
      tags: [admin-phone]
      summary: One VOIP registration, composed (admin)
      description: >
        One `voip_users` registration together with everything the detail screen shows beside it:
        the acquisitions of its digits, and its live proxy routes. The three were always fetched
        together, and two of them are meaningless without the first — the acquisitions exist here
        specifically to answer the question the registration raises, why `recordedProvider` and
        `effectiveProvider` disagree.


        Keyed on the registration id, NOT on the phone number, and that is load-bearing. Digits
        are not unique across `voip_users`: `voip_users_business_number_active_idx` is unique only
        among live rows, so a number given up and re-provisioned has a row per registration and a
        `showDeleted` listing returns all of them. A phone-number-keyed route could not say which
        one it meant. Every listing row already carries the id, so no extra lookup is needed to
        get here.


        All three parts are read from ONE `repeatable read, read only` database snapshot, as the
        listings take for their count and page. Composing from separately-timed reads is how a
        detail view comes to show a registration beside acquisitions that contradict it.


        A COMPOSITION endpoint, which couples the server to one screen's shape. That is a reason
        to keep it shallow rather than to keep adding to it: the assignments behind any proxy on
        `proxyRoutes` are deliberately left to
        `GET /admin/api/phoneNumbers/proxies/{proxy}/routes`, since a proxy is shared and folding them
        in would make this response's size depend on how many contacts other people have.
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: The `voip_users` id, as carried by every row of the registrations listing.
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: >
            The registration and its context. Returned for a GIVEN-UP registration too: a
            `showDeleted=true` listing links here, and an id that resolves to a registration
            resolves to something. Check `registration.deletedAt`.
          content:
            application/json:
              schema:
                type: object
                required: [registration, acquisitions, proxyRoutes, proxyRoutesTotal, proxyRoutesNextCursor]
                properties:
                  registration:
                    allOf:
                      - $ref: '#/components/schemas/VoipRegistrationRow'
                    description: >
                      The registration, in the LISTING's own shape — the same fields, from the
                      same column list, so a row and its detail cannot disagree.
                  acquisitions:
                    type: array
                    description: >
                      Every acquisition of the business number's DIGITS, newest first, released
                      ones included — the same list
                      `GET /admin/api/phoneNumbers/inventory/{phoneNumber}` returns.


                      The full history rather than the live row alone, because the history is why
                      this screen exists: it is where a port or a re-acquisition becomes legible.
                      Unpaged, because it is bounded by how many times Jamb has acquired ONE
                      number rather than by anything that grows with the table.


                      Keyed on the digits and therefore NOT scoped to this registration — a row
                      here may describe an acquisition made for somebody else. Match on
                      `providerNumberId` before concluding a row describes this registration's
                      holding.
                    items:
                      $ref: '#/components/schemas/PhoneNumberInventoryRow'
                  proxyRoutes:
                    type: array
                    description: >
                      The FIRST PAGE of the live proxy routes of THIS registration, resolved by
                      its id rather than by its digits: the anchor lookup matches either number of
                      a pairing, so it can answer with a second user's routes, and for a given-up
                      registration it would answer with the routes of whoever holds those digits
                      now.


                      A page rather than all of them (#1641), and this is the one part of this
                      response with a bound worth stating: `acquisitions` above is bounded by how
                      many times Jamb has acquired one number, while this grows with how many
                      contacts the user has called through a proxy. At most 100 rows; the rest are
                      at `GET /admin/api/phoneNumbers/registrations/{id}/proxyRoutes`.


                      Empty for a given-up registration, and that is the truth rather than a
                      filter artifact — giving a registration up soft-deletes its assignments with
                      it, so there are none left to report.
                    items:
                      $ref: '#/components/schemas/VoipProxyRoute'
                  proxyRoutesTotal:
                    type: integer
                    description: >
                      How many live routes this registration has in total, however many of them
                      are in `proxyRoutes`. Counted in the same snapshot as the rest of this
                      response, so a screen can say "showing 100 of N" without a second read that
                      might disagree with the page above it.
                  proxyRoutesNextCursor:
                    type: [string, 'null']
                    description: >
                      Where the next page of routes starts, or null when `proxyRoutes` is all of
                      them.


                      Fed to `GET /admin/api/phoneNumbers/registrations/{id}/proxyRoutes`, NOT
                      back to this route: paging a composed response would re-read the
                      registration and its whole acquisition history to deliver a sub-list. This
                      call is for the screen's first paint; that one is for the rest of the table.
        '400':
          description: The id is not a uuid.
        '401':
          description: Missing or invalid admin credentials
        '404':
          description: >
            No registration has this id. Reserved for an id that names NOTHING — a given-up
            registration is returned with a 200 and a non-null `deletedAt`.

  /admin/api/phoneNumbers/proxies:
    get:
      tags: [admin-phone]
      summary: List the VOIP proxy pool (admin)
      description: >
        The shared pool of call-bridging proxy numbers, paged and filtered — the replacement for
        `GET /admin/api/voip/proxy`, which returned the whole table in one response with no filter
        and no cursor, and was removed in #1642.


        Beside the inventory and registrations listings rather than under `/voip` because the
        shape and the audience are theirs: a proxy is a `phone_number_inventory` row with
        `ownerKind=voip_proxy`, and this pages it with the same cursor contract and the same
        provider vocabulary. What keeps it from being an inventory query is `usedBy`.


        Paged by an opaque cursor over the phone number, which is a total order here — the pool
        holds one row per number, unlike the inventory (a row per acquisition) and `voip_users`
        (a row per registration), whose cursors each carry a record id beside the digits. The
        guarantee is the inventory listing's: a proxy MATCHING THE FILTER for the whole listing is
        returned exactly once. Each request re-evaluates the filter, so a proxy that joins the set
        behind the cursor is not shown by that listing — added with lower digits, or, under
        `spare=true`, one already passed whose last assignment was released while the reader
        walked on. Neither is a lost row; the next listing has it.


        `total` and `spareTotal` count the FILTERED SET without the cursor, so they answer "how
        many are there" rather than "how many are left" and do not shrink page by page. They are
        the two halves of the summary an operator reads before allocating — "N proxy numbers, M
        spare" — and they are counted in the same statement and the same snapshot as each other,
        so they cannot describe different pools.


        Removed proxies are never listed. A soft-deleted pool row has left the pool and is not a
        state an operator acts on, so — unlike a given-up registration — there is no flag for it.
      security:
        - BearerAuth: []
      parameters:
        - name: provider
          in: query
          required: false
          description: >
            Proxies at one provider, compared on `effectiveProvider` — which is what allocation
            scopes on, so this answers what can be allocated there rather than what a column says.


            `none` selects the proxies NO provenance resolves. It is a value rather than an
            absence, spelled as the inventory listing spells `ownerKind=none`: null provenance is
            never a statement that a number is Telnyx's, so `provider=telnyx` must not sweep those
            rows in, and omitting the parameter reads as "no filter" rather than as a question
            about them.


            One provider rather than a comma-separated set, unlike the registrations listing:
            allocation is per-provider, so `provider=X&spare=true` is the question actually being
            asked. An unrecognized name is rejected rather than ignored.
          schema:
            type: string
            enum: [telnyx, carrierx, none]
        - name: spare
          in: query
          required: false
          description: >
            `true` for the free pool (`usedBy = 0`), `false` for the proxies somebody currently
            reaches. Combining it with `assignedTo` is a 400 rather than an empty page: a spare is
            a proxy nobody reaches, so the pair is unsatisfiable rather than narrow, and an empty
            answer would read as "this user has no proxies".
          schema:
            type: boolean
        - name: assignedTo
          in: query
          required: false
          description: >
            The pool narrowed to the proxies ONE user reaches — either half of that user's
            pairing, business or personal number, as `/admin/api/phoneNumbers/proxies/routes`
            takes its `anchor`.


            A filter on the listing rather than a second call, and that is the point of it. A
            client that fetches the pool and intersects it with that route's answer is correct
            only while the first call returns everything; the moment the listing is paged, an
            intersection computed on the client is wrong.


            Matched through LIVE registrations only. An anchor can name two of them — one user's
            business number may be another's personal number — and both are returned, exactly as
            the anchor route returns both.
          schema:
            type: string
        - name: prefix
          in: query
          required: false
          description: Numbers whose digits start with this, e.g. `+1802`. Not required to be a whole number.
          schema:
            type: string
            maxLength: 20
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
        - name: cursor
          in: query
          required: false
          description: >
            A page boundary from a previous `nextCursor`. Opaque — it names a position in an
            ordering this endpoint chooses, and a caller that parses it breaks when the ordering
            changes. Validation is syntactic only; the cursor is unsigned deliberately, since it
            names a boundary in data the caller may already read in full.
          schema:
            type: string
      responses:
        '200':
          description: One page of the proxy pool
          content:
            application/json:
              schema:
                type: object
                required: [result, total, spareTotal, nextCursor]
                properties:
                  result:
                    type: array
                    items:
                      $ref: '#/components/schemas/ProxyPoolRow'
                  total:
                    type: integer
                    description: How many match the filter, ignoring the cursor
                  spareTotal:
                    type: integer
                    description: >
                      How many of that same filtered set are spares (`usedBy = 0`) — the second
                      half of "N proxy numbers, M spare".


                      Its own field rather than a second request with `spare=true&limit=1`,
                      because a second request is a second snapshot: a proxy allocated between the
                      two yields a summary line whose halves describe different pools.


                      Of the filtered set, which is what makes it composable —
                      `provider=carrierx` answers "how many CarrierX proxies, and how many of
                      those are free". It is therefore `0` under `spare=false` and equal to
                      `total` under `spare=true`.
                  nextCursor:
                    type: [string, 'null']
                    description: Absent when this is the last page
        '400':
          description: >
            Unrecognized provider, a `spare=true` that contradicts `assignedTo`, a malformed
            cursor, or an out-of-range limit
        '401':
          description: Missing or invalid admin credentials

    post:
      tags: [admin-phone]
      summary: Provision a new VOIP proxy-pool number (admin)
      description: >
        Orders a phone number from the named provider (the default when none is
        named) and adds it to the proxy pool. Orders a specific number when
        phoneNumber is given; otherwise orders the first available number
        matching the base filter (optionally narrowed by startsWith). The order
        record is kept in filesystem storage; pool membership is stored in the
        database. The response echoes the provider that answered.
      security:
        - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                provider:
                  $ref: '#/components/schemas/PhoneNumberProvider'
                phoneNumber:
                  type: string
                  description: Specific E.164 number to order (from the provisioning search)
                startsWith:
                  type: string
                  description: Phone number prefix filter for the availability search (ignored when phoneNumber is given)
      responses:
        '201':
          description: Proxy number ordered and added to the pool
          content:
            application/json:
              schema:
                type: object
                properties:
                  phoneNumber:
                    type: string
                    description: Provisioned proxy phone number (E.164)
                  # The provider that actually ANSWERED, echoed from the binding this request
                  # acquired through rather than from its own `provider` field — a request
                  # that named nothing gets the default and still has to be told what it
                  # bought from. See the component's own note on request vs response.
                  provider:
                    $ref: '#/components/schemas/PhoneNumberProvider'
                  providerInfo:
                    type: object
                    additionalProperties:
                      type: string
                    description: >
                      Provider metadata: the Telnyx phone number id, and `orderId` — an opaque
                      provenance token, not a bare order id. See `providerOrderId` on
                      PhoneNumberInventoryRow for its spelling.
        '400':
          description: Invalid request body
        '403':
          description: >
            The number cannot be provisioned, and retrying this request will not
            change that. Covers a number the provider will not sell, one it sold
            but that cannot carry ordinary traffic (for example emergency-only),
            and a specific phoneNumber that was ordered but did not come up
            usable — re-ordering those digits is refused because the account now
            owns them. A number bought and not used is detached and recorded in
            the phone-number inventory rather than kept.
        '500':
          description: Upstream Telnyx error
        '503':
          description: >
            The number ordered for this request is still settling at the
            provider and no specific phoneNumber was pinned, so retrying will
            search again and can succeed with different digits. Only returned
            when the number was chosen for the caller; a pinned number that is
            still settling returns 403, since the same request cannot succeed.

  /admin/api/phoneNumbers/proxies/repair:
    post:
      tags: [admin-phone]
      summary: Repair cross-provider proxy assignments (admin)
      description: >
        Replaces every proxy assignment whose proxy is held at a different
        provider from its owner's business number. Such an assignment is already
        unroutable — a proxy cannot present an ANI from another carrier — but it
        cannot simply be deleted, because the proxy is still saved in the owner's
        contacts and freeing it would let a later assignment point those digits
        at a different person.

        Each repair allocates a replacement proxy at the owner's own provider
        first, then supersedes the old assignment and notifies the owner's agent;
        the old proxy stays reserved until the client acknowledges. An assignment
        with no compatible proxy available is reported and left exactly as it is,
        never half-repaired. Idempotent: a repaired assignment drops out of the
        listing, so a second run finds nothing.
      security:
        - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                dryRun:
                  type: boolean
                  default: false
                  description: >
                    Report the assignments that would be repaired and change
                    nothing. The preview walks the same listing a real run acts
                    on.
      responses:
        '200':
          description: What the sweep did, or would do
          content:
            application/json:
              schema:
                type: object
                properties:
                  dryRun:
                    type: boolean
                  result:
                    type: array
                    description: >
                      One entry per assignment, in the same shape either way: a
                      dry run reports `would-repair` and a null
                      `newProxyNumber`, so a preview cannot disagree in shape
                      with the run that follows it.
                    items:
                      type: object
                      properties:
                        assignmentId:
                          type: string
                          format: uuid
                        ownerBusinessNumber:
                          type: string
                          description: The owner's JBN (E.164)
                        externalNumber:
                          type: string
                          description: The external party this proxy stands in for (E.164)
                        oldProxyNumber:
                          type: string
                          description: The mismatched proxy (E.164)
                        # Plain strings rather than the PhoneNumberProvider enum, for the
                        # reason the pool listing above gives: these are READ BACK from
                        # `phone_number_inventory.provider`, which is plain TEXT so that a row
                        # written by another deployment can name a provider this build has
                        # never heard of and still read rather than explode.
                        oldProxyProvider:
                          type: string
                        ownerProvider:
                          type: string
                        newProxyNumber:
                          # 3.1 spelling: `nullable: true` is a 3.0 keyword and is not valid
                          # here. The pool listing above says "nullable" only in a comment for
                          # the same reason.
                          type: [string, 'null']
                          description: >
                            The replacement proxy, or null when none was
                            available at the owner's provider.
                        outcome:
                          type: string
                          enum: [repaired, would-repair, no-compatible-proxy, not-mismatched, failed]
                        notified:
                          type: boolean
                          description: >
                            Whether the owner's agent was handed the notice.
                            False is ordinary — an offline agent picks it up on
                            its next on-connect drain.
        '401':
          description: Missing or invalid admin credentials

  /admin/api/phoneNumbers/proxies/routes:
    get:
      tags: [admin-phone]
      summary: List a VOIP user's assigned proxy numbers (admin)
      description: >
        Returns the active proxy assignments of the VOIP user whose business
        number or personal number equals the anchor, considering only active
        users and active assignments. Each entry carries the user's pairing
        (personal and business number) alongside the proxy and the external
        party it stands for. An anchor that matches no user, or a user with no
        assignments, returns an empty list.


        PAGED, ordered by proxy number (#1641). What bounds this answer is how many contacts the
        user has called through a proxy — user-controlled, monotonically increasing, and capped
        nowhere in allocation — so it was previously bounded only by omission. `total` is the
        size of the whole set rather than of the page, so a client showing one page can still say
        how much of the set it is showing.


        An anchor can match TWO users, since one user's personal number may be another's business
        number; both users' routes are in the same listing, which is why every entry carries the
        whole pairing. To resolve routes by REGISTRATION rather than by digits — and so exclude a
        second user, and answer correctly for a given-up registration — use
        `GET /admin/api/phoneNumbers/registrations/{id}/proxyRoutes`.
      security:
        - BearerAuth: []
      parameters:
        - name: anchor
          in: query
          required: true
          schema:
            type: string
          description: >
            E.164 business number or personal number of the VOIP user. A QUERY parameter rather
            than a path segment: it is not what this listing is of — `/proxies/{proxy}/routes`
            names the proxy whose routes those are, while an anchor names the party at the other
            end of them.
        - name: limit
          in: query
          description: Page size.
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
        - name: cursor
          in: query
          description: >
            The previous page's `nextCursor`. Opaque — it names a position in an ordering this
            endpoint chooses, and a caller that parses it breaks when the ordering changes. A
            cursor belongs to the listing that issued it: the proxy-route lookups order by
            different columns depending on which key was used, so feeding one endpoint's cursor
            to another resumes at a position that means nothing.


            Validation is SYNTACTIC only: a value that does not decode to a sort key and a uuid
            is a 400, but a well-formed one this endpoint never issued is accepted and read as
            the position it describes. Unsigned deliberately — it names a boundary in data the
            caller may already read in full, so a fabricated one can only ask for rows the same
            request could have asked for anyway.
          schema:
            type: string
      responses:
        '200':
          description: One page of the user's proxy assignments
          content:
            application/json:
              schema:
                type: object
                required: [result, total, nextCursor]
                properties:
                  result:
                    type: array
                    items:
                      $ref: '#/components/schemas/VoipProxyRoute'
                  total:
                    type: integer
                    description: >
                      The size of the whole matching set, not of this page — as of the moment it
                      was asked, since each request takes its own snapshot. Counted in the same
                      snapshot as the page, so the two cannot describe different sets.
                  nextCursor:
                    type: [string, 'null']
                    description: >
                      Pass back as `cursor` for the next page. Null on the last page, including
                      when that page came back full.
        '400':
          description: Invalid phone number, limit, or cursor

  /admin/api/phoneNumbers/proxies/{proxy}/routes:
    get:
      tags: [admin-phone]
      summary: List the active assignments of one proxy number (admin)
      description: >
        Returns the active assignments of the given pool proxy number: one
        entry per VOIP user currently reachable through it, carrying that
        user's pairing (personal and business number) and the external party
        the proxy stands for in that route. A proxy means a different contact
        per user, so several entries are expected. A proxy with no active
        assignments — a spare, or one not in the pool — returns an empty list.


        PAGED, ordered by business number (#1641). This is the widest of the three route lookups:
        its size grows with how widely the proxy is shared AND with how many contacts each of
        those users has, neither of which anything caps.


        The same rows `GET /admin/api/phoneNumbers/proxies/routes` returns, read from the other
        side — but the two order differently, so a cursor from one is not a position in the
        other.
      security:
        - BearerAuth: []
      parameters:
        - name: proxy
          in: path
          required: true
          schema:
            type: string
          description: E.164 pool proxy number
        - name: limit
          in: query
          description: Page size.
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
        - name: cursor
          in: query
          description: >
            The previous page's `nextCursor`. Opaque, syntactically validated, and belonging to
            the listing that issued it — see the same parameter on
            `GET /admin/api/phoneNumbers/proxies/routes`.
          schema:
            type: string
      responses:
        '200':
          description: One page of the proxy number's active assignments
          content:
            application/json:
              schema:
                type: object
                required: [result, total, nextCursor]
                properties:
                  result:
                    type: array
                    items:
                      $ref: '#/components/schemas/VoipProxyRoute'
                  total:
                    type: integer
                    description: >
                      The size of the whole matching set, not of this page — as of the moment it
                      was asked. Counted in the same snapshot as the page.
                  nextCursor:
                    type: [string, 'null']
                    description: >
                      Pass back as `cursor` for the next page. Null on the last page, including
                      when that page came back full.
        '400':
          description: Invalid phone number, limit, or cursor

  /admin/api/phoneNumbers/available:
    get:
      tags: [admin-phone]
      summary: Search available phone numbers for provisioning (admin)
      description: >
        The numbers that can be bought right now, as the provider reports them. Offers can be
        passed to the reserve endpoint to provision a specific one.


        A GET, unlike the `POST /admin/api/voip/provisioning/search` it replaces (#1657): this
        reads availability and changes nothing, so the filter travels in the query string rather
        than in a provider-shaped body. Same three parameters, spelled the same way, as the
        org-side `GET /api/org/{orgId}/provisioning/search`.
      security:
        - BearerAuth: []
      parameters:
        - name: provider
          in: query
          description: >
            Which provider to ask. Omit for the deployment default; each offer echoes the
            provider that ANSWERED, which is what a reserve must then be given.
          schema:
            $ref: '#/components/schemas/PhoneNumberProvider'
        - name: startsWith
          in: query
          description: Digits the number must start with, without the leading `+` (e.g. `1617`).
          schema:
            type: string
            pattern: '^\d+$'
        - name: limit
          in: query
          description: How many offers to return.
          schema:
            type: integer
            minimum: 1
            maximum: 50
      responses:
        '200':
          description: List of available phone numbers
          content:
            application/json:
              schema:
                type: object
                properties:
                  result:
                    type: array
                    items:
                      type: object
                      properties:
                        phoneNumber:
                          type: string
                        location:
                          type: string
                          description: Human-readable region name (city, state, country)
                        provider:
                          $ref: '#/components/schemas/PhoneNumberProvider'
        '422':
          description: >
            The request is well formed and this deployment's phone number provider cannot apply
            part of it. Permanent for this deployment: retrying the same request will not
            succeed. Distinct from 400, which means the query itself was invalid.
        '400':
          description: Invalid query
        '500':
          description: Upstream provider error

  /admin/api/phoneNumbers/lookup/{phoneNumber}:
    get:
      tags: [admin-phone]
      summary: Look up carrier info for a phone number (admin)
      description: >
        Performs a Telnyx number lookup and enriches the result with carrier
        brand, code family, and unconditional call-forward codes from the
        internal carrier map.
      security:
        - BearerAuth: []
      parameters:
        - name: phoneNumber
          in: path
          required: true
          schema:
            type: string
          description: E.164-formatted phone number
        - name: type
          in: query
          required: false
          schema:
            type: string
            enum: [carrier, caller-name]
          description: Limit lookup to carrier or caller-name data only
      responses:
        '200':
          description: Lookup result with carrier info
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NumberLookupResult'

  /admin/api/phoneNumbers/inventory:
    get:
      tags: [admin-phone]
      summary: List the phone number inventory
      description: >
        Read-only listing of `phone_number_inventory` — the record of where each Jamb-owned
        number came from and which product holds it (specs/phone-number-inventory.md).


        Paged by CURSOR rather than by offset. The inventory is being claimed and released while
        it is read, and a page located by counting rows from the start shifts underneath the
        reader: a number claimed between two pages moves the boundary, and the reader either
        never sees the row that crossed it or sees another one twice. Pass the previous page's
        `nextCursor` back as `cursor`; a null `nextCursor` is the last page.


        The guarantee is that a row MATCHING THE FILTER for the whole listing is returned exactly
        once — membership, not merely existence. Each request re-evaluates the filter, so a row
        that joins the set BEHIND the cursor is not shown by that listing, whether it joined by
        being recorded there or by changing into a match: under `ownerKind=none`, a held number
        released while the reader is already past its position existed the whole time and still
        will not appear. Neither case is a lost row — the next listing has it. Rows are ordered by
        phone number, then by record id.


        `total` is the size of the filtered set, not of the page, and does not change as the
        reader walks through it.
      security:
        - BearerAuth: []
      parameters:
        - name: limit
          in: query
          description: Page size.
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
        - name: cursor
          in: query
          description: >
            The previous page's `nextCursor`. Opaque — it names a position in an ordering this
            endpoint chooses, and a caller that parses it breaks when the ordering changes.


            Validation is SYNTACTIC only: a value that does not decode to a phone number and a
            uuid is a 400, but a well-formed one this endpoint never issued is accepted and read
            as the position it describes. The cursor is unsigned deliberately — it names a
            boundary in data the caller is already authorized to read in full, so a fabricated one
            can only ask for rows the same request could have asked for anyway.
          schema:
            type: string
        - name: ownerKind
          in: query
          description: >
            Which product holds the numbers. `none` is held by NOTHING — which is not by itself
            the free pool: a record retired while unowned is held by nothing too, and `state`
            defaults to `all`. For the pool, pass `ownerKind=none&state=live`; for the part of it
            waiting out a quarantine, `resting=true`, which carries the liveness itself.
            `assistant` and `agent` name retired products whose historical rows still read back.
          schema:
            type: string
            enum: [voip_user, answering_service, voip_proxy, assistant, agent, none]
        - name: resting
          in: query
          description: >
            Only numbers that are (true) or are not (false) waiting out their quarantine. A
            number is resting when it is free, still Jamb's, and inside the window — a held or
            released number is never resting, whatever its `outOfServiceAt` says.
          schema:
            type: boolean
        - name: claimPending
          in: query
          description: >
            Only numbers where a taking is (true) or is not (false) in progress and not yet
            corroborated by the claiming product's own record — `claimPendingAt` set or null.
            A marker much older than a request's lifetime is an acquisition that never completed
            (specs/phone-number-inventory.md B19), and those numbers are held by a product with
            no record of them, so nothing else asked of either side can see them.


            An independent axis: a marker can sit on a held row or an unowned one, live or
            retired, so this narrows alongside `ownerKind`, `resting` and `state` rather than
            implying any of them. The stuck-taking sweep is `claimPending=true&state=live`.


            Nothing bounds this set, and it is paged like any other: markers are REPORTED rather
            than reclaimed, so every taking that ever died and was never put right is still in
            it. Narrow by age with `claimPendingForMinutes` rather than by filtering a page after
            it arrives — a client that age-filters the first page alone silently misses every
            stuck taking behind the cursor.
          schema:
            type: boolean
        - name: claimPendingForMinutes
          in: query
          description: >
            Only numbers whose taking has been in progress for at least this many minutes — the
            actionable half of B19. A marker a few seconds old is an acquisition running
            normally; one much older than any request's lifetime is a process that died
            mid-taking, leaving the number held by a product with no record of it.


            Where the line falls is yours: "unfinished" and "slow" look identical from the
            outside, so the inventory takes the threshold rather than choosing one, and reports
            rather than reclaims whatever is found.


            A DURATION rather than an instant, so the comparison is made against the database's
            clock rather than the caller's — a marker minutes old is exactly the scale at which
            that difference shows. Implies `claimPending=true` (a number with no taking in
            progress cannot have had one for ten minutes), and passing it with
            `claimPending=false` is a 400 rather than an empty page, which would read as
            "nothing is stuck".
          schema:
            type: integer
            minimum: 1
            maximum: 525600
        - name: prefix
          in: query
          description: Numbers whose digits start with this, e.g. `+1425`. Not required to be a whole number.
          schema:
            type: string
            maxLength: 20
        - name: provider
          in: query
          description: Which provider the numbers were acquired from.
          schema:
            type: string
            enum: [telnyx, carrierx]
        - name: state
          in: query
          description: >
            `live` = numbers Jamb still holds, `released` = numbers given back to the provider,
            `all` = both. Release retires a record rather than deleting it, so the history is
            always there to be asked for.


            Independent of `ownerKind`: a retired record keeps whatever owner it was retired
            with, so `ownerKind=none&state=released` asks which numbers were being paid for and
            serving nobody when Jamb gave them back.
          schema:
            type: string
            enum: [all, live, released]
            default: all
      responses:
        '200':
          description: One page of the inventory
          content:
            application/json:
              schema:
                type: object
                required: [result, total, nextCursor]
                properties:
                  result:
                    type: array
                    items:
                      $ref: '#/components/schemas/PhoneNumberInventoryRow'
                  total:
                    type: integer
                    description: >
                      How many rows match the filter — the whole filtered set, not this page, and
                      never reduced by the cursor. Read from the same database snapshot as the
                      rows beside it, so the two halves of one reply always describe the same
                      table.


                      That agreement is WITHIN one reply. Each request takes its own snapshot, so
                      a number claimed, released, recorded, or simply ageing out of a `resting`
                      filter between two pages changes the total the second one reports. It is the
                      size of the set at the moment it was asked, not a fixed length for the walk.
                  nextCursor:
                    type: [string, 'null']
                    description: Pass back as `cursor` for the next page. Null on the last page.
        '400':
          description: Invalid query — a cursor that does not decode, or a filter value outside its enum.

  /admin/api/phoneNumbers/registrations/{id}/proxyRoutes:
    get:
      tags: [admin-phone]
      summary: One registration's proxy routes, paged (admin)
      description: >
        The live proxy routes of ONE registration — one entry per external contact that
        registration reaches through a pool proxy, carrying the user's pairing, the proxy, and the
        party it stands for.


        The composed detail route above delivers the FIRST page of these inside its response, so a
        screen still paints in one call; this is where its `proxyRoutesNextCursor` is fed back. A
        second route rather than a cursor parameter on the composition, because paging the
        composition would re-read the registration and its whole acquisition history to deliver a
        sub-list.


        Keyed on the REGISTRATION id, exactly as the detail is, and not on the digits.
        `GET /admin/api/phoneNumbers/proxies/routes?anchor=` answers the same question by
        number, but an anchor matches either half of a pairing — so it can include a second user's routes, and
        for a given-up registration it answers with the routes of whoever holds those digits now.


        Ordered by proxy number. A given-up registration has no routes: `removeUser` soft-deletes
        its assignments with the user row, so an empty page there is the truth rather than a
        filter artifact.
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: The `voip_users` id, as carried by every row of the registrations listing.
          schema:
            type: string
            format: uuid
        - name: limit
          in: query
          description: Page size.
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
        - name: cursor
          in: query
          description: >
            The previous page's `nextCursor` — or the composed detail route's
            `proxyRoutesNextCursor`, which is a boundary in this same ordering. Opaque and
            syntactically validated; see the same parameter on
            `GET /admin/api/phoneNumbers/proxies/routes`.
          schema:
            type: string
      responses:
        '200':
          description: >
            One page of the registration's live proxy routes. An id that names NOTHING answers
            with an empty page rather than a 404, as `/inventory/{phoneNumber}` does for a number
            Jamb never held: this is a listing of what routes through a registration, and "nothing
            does" is an answer to that question. The detail route is where an id is resolved, and
            it 404s.
          content:
            application/json:
              schema:
                type: object
                required: [result, total, nextCursor]
                properties:
                  result:
                    type: array
                    items:
                      $ref: '#/components/schemas/VoipProxyRoute'
                  total:
                    type: integer
                    description: >
                      How many live routes this registration has, not how many are on this page.
                      Counted in the same snapshot as the page.
                  nextCursor:
                    type: [string, 'null']
                    description: >
                      Pass back as `cursor` for the next page. Null on the last page, including
                      when that page came back full.
        '400':
          description: The id is not a uuid, or the limit or cursor is malformed.
        '401':
          description: Missing or invalid admin credentials

  /admin/api/phoneNumbers/inventory/{phoneNumber}:
    get:
      tags: [admin-phone]
      summary: Every acquisition of one phone number
      description: >
        Every inventory record for these digits, live and retired, newest first.


        EVERY record, because digits outlive records: a number released and bought again is a new
        acquisition with its own provider id, order and dates, and the earlier one is retired
        rather than erased (specs/phone-number-inventory.md B4, B5, B51). Answering "who held
        this last" from the live record alone gives the last holder of the CURRENT acquisition,
        which for a reacquired number is a different — and silently different — answer.


        A number Jamb has never held returns an empty list rather than a 404: this is a listing
        of what has happened to a number, and "nothing has" is an answer to it.
      security:
        - BearerAuth: []
      parameters:
        - name: phoneNumber
          in: path
          required: true
          description: E.164.
          schema:
            type: string
      responses:
        '200':
          description: The number's acquisitions, newest first
          content:
            application/json:
              schema:
                type: object
                required: [result]
                properties:
                  result:
                    type: array
                    items:
                      $ref: '#/components/schemas/PhoneNumberInventoryRow'
        '400':
          description: Not a valid E.164 phone number.

  /admin/api/phoneNumbers/inventory/{phoneNumber}/claim:
    delete:
      tags: [admin-phone]
      summary: End a taking that never completed
      description: >
        Gives back a pending claim on the live inventory record for these digits — the taking a
        dying acquisition never got to undo (specs/phone-number-inventory.md B19, B75).


        Every ordinary failure between taking a claim and settling it is compensated by the
        acquisition itself. One that stops the process outright leaves the number held by a
        product that has no record of it: invisible to both sides, offered to nobody, and never
        aged out, because quarantine measures a number that LEFT service and this one never
        entered it. This is an operator standing in for that compensation.


        The claim goes back the way the acquisition would have given it back — matched on the
        owner AND the attempt token the row carries, and giving back exactly what the taking took
        (B22): a first taking releases the ownership with the marker, a retry that found the
        owner already there gives back only the marker.


        REFUSED BY DEFAULT WHERE THE HOLDER STILL HAS ITS OWN RECORD. A claim whose product row
        exists is not a stuck taking: what went missing is the settle, and freeing the number
        there takes it out from under a product that is using it. `force=true` overrides that,
        and only that — it is never a silent default, and the answer reports that it was used.


        There is no age threshold, unlike the sweep: the operator looking at the row is the age
        check.
      security:
        - BearerAuth: []
      parameters:
        - name: phoneNumber
          in: path
          required: true
          description: E.164.
          schema:
            type: string
        - name: force
          in: query
          required: false
          description: >
            Give the claim back even though the holder's own record exists. Ask for it
            explicitly; absent means false.
          schema:
            type: string
            enum: ['true', 'false']
      responses:
        '200':
          description: The claim was given back
          content:
            application/json:
              schema:
                type: object
                required: [phoneNumber, owner, claimPendingAt, ownerRecord, forced]
                properties:
                  phoneNumber:
                    type: string
                    description: E.164.
                  owner:
                    type: object
                    description: Who held the claim that was given back.
                    required: [kind, id]
                    properties:
                      kind:
                        type: string
                      id:
                        type: string
                  claimPendingAt:
                    type: string
                    format: date-time
                    description: When the taking that was ended began.
                  ownerRecord:
                    type: object
                    description: >
                      What the guard found. `absent` is the ordinary case — a product with no
                      record of the number. `present` only appears alongside `forced: true`.
                      `unverifiable` means this build has no records for that owner kind.
                    required: [state, table]
                    properties:
                      state:
                        type: string
                        enum: [present, absent, unverifiable]
                      table:
                        type: [string, 'null']
                        description: The table consulted, or null where there was none.
                  forced:
                    type: boolean
                    description: Whether the product-record guard was overridden.
        '400':
          description: Not a valid E.164 phone number, or a `force` that is neither true nor false.
        '404':
          description: >
            No live inventory record for these digits — never Jamb's, or already released. A
            retired record is history and holds nothing.
        '409':
          description: >
            Nothing was written, and the message says which of these it was: the record carries
            no taking in progress; the holder's own record exists and `force` was not asked for;
            the owner kind is one this build cannot ask about; the marker is missing the owner or
            the token the compensation matches on; or the row changed between being read and
            being written, so the compare-and-set declined.
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    type: string
                  message:
                    type: string

  /admin/api/phoneNumbers/inventory/{phoneNumber}/claim:settle:
    post:
      tags: [admin-phone]
      summary: Finish a taking that completed and never said so
      description: >
        Settles a pending claim on the live inventory record for these digits — the confirmation
        an acquisition that succeeded never got to write (specs/phone-number-inventory.md B76).


        The other half of `DELETE .../claim`, and the repair for the opposite fault. A taking
        that never completed leaves a number held by a product with no record of it, and the
        claim has to go BACK. A taking that completed and never settled leaves the marker on a
        number that is in service: the product's own record exists, the number is carrying calls,
        and what is missing is only the confirmation. Every proxy order placed through the claim
        path was in that state until #1570 — the marker made the number unreconcilable by the
        ownership backfill, printed it in the overdue-claim report as an acquisition that did not
        finish, and refused any re-acquisition of the same digits.


        The claim is settled the way the acquisition would have settled it — matched on the owner
        AND the attempt token the row carries — so a claim that has since passed to another
        attempt is refused rather than confirmed.


        REFUSED BY DEFAULT WHERE THE HOLDER HAS NO RECORD OF THESE DIGITS. That is the clear's
        guard with the sense reversed, and the two are not interchangeable: clearing a row whose
        order completed advertises a number carrying calls as free, while settling a row whose
        order genuinely failed leaves an owner with nothing behind it — which the ownership
        backfill then clears while stamping `out_of_service_at`, putting the number into a 30-day
        quarantine instead of back in the pool.


        A record that EXISTS is not enough: it has to name this number. A holder can keep its id
        across a number change, so a marker stranded on the number it moved off still finds a
        live record — and settling on the strength of that confirms a holder that no longer
        references the number. The clear applies no such test and must not; existence is the safe
        question on that side, because a registration moving a user onto this number has not yet
        rewritten the number on its own record. An owner kind this build cannot ask about is
        refused by both. `force=true` overrides that one check, explicitly, and the answer reports
        that it was used.


        There is no age threshold: the operator looking at the row is the age check.
      security:
        - BearerAuth: []
      parameters:
        - name: phoneNumber
          in: path
          required: true
          description: E.164.
          schema:
            type: string
        - name: force
          in: query
          required: false
          description: >
            Settle the claim even though the holder has no record of its own. Ask for it
            explicitly; absent means false.
          schema:
            type: string
            enum: ['true', 'false']
      responses:
        '200':
          description: The claim was settled
          content:
            application/json:
              schema:
                type: object
                required: [phoneNumber, owner, claimPendingAt, ownerRecord, forced]
                properties:
                  phoneNumber:
                    type: string
                    description: E.164.
                  owner:
                    type: object
                    description: >
                      Who holds the number. Left exactly as it was — settling confirms a holding,
                      it never moves or clears one.
                    required: [kind, id]
                    properties:
                      kind:
                        type: string
                      id:
                        type: string
                  claimPendingAt:
                    type: string
                    format: date-time
                    description: When the taking that was finished began.
                  ownerRecord:
                    type: object
                    description: >
                      What the guard found. `present` with `phoneNumber` equal to these digits is
                      the ordinary case — the product's own record of the number, which is what
                      makes this a missing settle. `absent`, `unverifiable`, and `present` naming
                      other digits only appear alongside `forced: true`.
                    required: [state, table, phoneNumber]
                    properties:
                      state:
                        type: string
                        enum: [present, absent, unverifiable]
                      table:
                        type: [string, 'null']
                        description: The table consulted, or null where there was none.
                      phoneNumber:
                        type: [string, 'null']
                        description: >
                          The number the product's own record names, or null where there was no
                          record to read it off. Reported beside the state because `present`
                          alone cannot tell a record that corroborated these digits from one
                          that named others.
                  forced:
                    type: boolean
                    description: Whether the product-record guard was overridden.
        '400':
          description: Not a valid E.164 phone number, or a `force` that is neither true nor false.
        '404':
          description: >
            No live inventory record for these digits — never Jamb's, or already released. A
            retired record is history and holds nothing.
        '409':
          description: >
            Nothing was written, and the message says which of these it was: the record carries
            no taking in progress; the holder has no record of its own, or has one that names
            different digits, and `force` was not asked for — in both the repair is the clear
            rather than the settle; the owner kind is one this build cannot ask about; the marker
            is missing the owner or the token the settle matches on; or the row changed between
            being read and being written, so the compare-and-set declined.
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    type: string
                  message:
                    type: string

  /admin/api/phoneNumbers/{phoneNumber}:
    get:
      tags: [admin-accounts]
      summary: Get the stored Signal account record by phone number
      description: >
        The RAW stored record — `getAccount(phoneNumber)` passed straight through — not the
        assembled account the `/admin/api/accounts` reads return. Different shape, different
        field names, and no agent or provisioned-number fields: see `SignalAccountRecord`.
      security:
        - BearerAuth: []
      parameters:
        - name: phoneNumber
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The stored account record
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SignalAccountRecord'
        '404':
          description: Not found

  /admin/api/directory:lookup:
    post:
      tags: [admin-accounts]
      summary: CDSI directory lookup
      security:
        - BearerAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                e164s:
                  type: array
                  items:
                    type: string
                acisAndAccessKeys:
                  type: array
                  items:
                    type: object
      responses:
        '200':
          description: Directory entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  entries:
                    type: array
                  debugPermitsUsed:
                    type: integer

  /admin/api/agent:
    get:
      tags: [admin-agents]
      summary: Get latest agent Docker image
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Latest image info
          content:
            application/json:
              schema:
                type: object
                properties:
                  Id:
                    type: string

  /admin/api/agent/images:
    get:
      tags: [admin-agents]
      summary: List all agent Docker images (local and remote)
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Image names
          content:
            application/json:
              schema:
                type: array
                items:
                  type: string

  /admin/api/agent/branches/v2:
    get:
      tags: [admin-agents]
      summary: Get available branches with details
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Branch details
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object

  /admin/api/agent/featureFlags:
    get:
      tags: [admin-agents]
      summary: Get current feature flags
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Feature flags
          content:
            application/json:
              schema:
                type: object

  /admin/api/userAgents:
    get:
      tags: [admin-agents]
      summary: List all user agents
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Agent list
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    userServiceId:
                      type: string
                    kind:
                      type: string
                      enum: [user, relay, answeringService]
                      description: >
                        Who the agent belongs to: a person, an external number's relay, or
                        an answering service. Says who OWNS the agent, not which runtime it
                        runs — an answering service's agent runs the same AI runtime a
                        person's does. Read this rather than inferring an agent's kind from
                        the shape of `userServiceId` or the prefix of `username`.
                    serviceId:
                      type: string
                      description: The agent's Signal ACI.
                    username:
                      type: string
                    busy:
                      type: boolean
                    profileName:
                      oneOf:
                        - $ref: '#/components/schemas/AgentProfileName'
                        - type: 'null'
                      description: >-
                        The Signal profile name this agent was last observed
                        carrying — what a standard Signal client renders for it.
                        `null` when this deployment has not observed one (a
                        freshly minted agent that has not woken yet, or a
                        profile carrying no usable name). Never inferred from
                        configuration: it reports the name an agent HAS, not the
                        name it should have.
                    userInfo:
                      type: [object, 'null']
                    container:
                      type: object
                    image:
                      type: [object, 'null']

  /admin/api/userAgents/{userId}:
    get:
      tags: [admin-agents]
      summary: Get specific user agent
      security:
        - BearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Agent info
          content:
            application/json:
              schema:
                type: object
        '404':
          description: Not found

    put:
      tags: [admin-agents]
      summary: Create agent for user
      security:
        - BearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      responses:
        '201':
          description: Agent created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentAccount'

    delete:
      tags: [admin-agents]
      summary: Delete user agent
      security:
        - BearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Agent deleted

  /admin/api/userAgents/{userId}/logs/stream:
    post:
      tags: [admin-agents]
      summary: Stream agent logs as Server-Sent Events
      security:
        - BearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
        - name: since
          in: query
          schema:
            type: string
        - name: until
          in: query
          schema:
            type: string
        - name: tail
          in: query
          schema:
            type: integer
      responses:
        '200':
          description: SSE log stream
          content:
            text/event-stream:
              schema:
                type: string

  /admin/api/host/logs/stream:
    post:
      tags: [admin-host]
      summary: Stream host container logs as Server-Sent Events
      security:
        - BearerAuth: []
      parameters:
        - name: since
          in: query
          schema:
            type: integer
        - name: until
          in: query
          schema:
            type: integer
        - name: tail
          in: query
          schema:
            oneOf:
              - type: integer
                minimum: 1
              - type: string
                enum: [all]
      responses:
        '200':
          description: SSE log stream
          content:
            text/event-stream:
              schema:
                type: string

  /admin/api/userAgents/{userId}:wake:
    post:
      tags: [admin-agents]
      summary: Start user agent
      security:
        - BearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Agent started

  /admin/api/userAgents/{userId}:sleep:
    post:
      tags: [admin-agents]
      summary: Stop user agent
      security:
        - BearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Agent stopped

  /admin/api/userAgents/{userId}:restart:
    post:
      tags: [admin-agents]
      summary: Restart user agent with options
      security:
        - BearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                featureFlags:
                  type: object
                  description: >-
                    Per-agent feature flag overrides, applied as container env for this
                    restart only. Flags are not inherited from the previous container:
                    any flag omitted here falls back to the agent image's built-in
                    default. Send the complete set of flags you want overridden.
                  additionalProperties:
                    type: boolean
                image:
                  type: string
                branch:
                  type: string
                cmd:
                  type: array
                  items:
                    type: string
      responses:
        '200':
          description: SSE stream showing restart progress
          content:
            text/event-stream:
              schema:
                type: string

  /admin/api/userAgents/{userId}:upgrade:
    post:
      tags: [admin-agents]
      summary: Upgrade user agent to latest image
      security:
        - BearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Upgrade result
          content:
            application/json:
              schema:
                type: object
                properties:
                  upgraded:
                    type: boolean

  /admin/api/userAgents/{userId}:snapshot-logs:
    post:
      tags: [admin-agents]
      summary: Upload agent log snapshot to GCS
      security:
        - BearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: GCS location
          content:
            application/json:
              schema:
                type: object
                properties:
                  bucket:
                    type: string
                  objectName:
                    type: string
        '404':
          description: Agent not found

  /admin/api/userAgents/{userId}/rate-limits:
    get:
      tags: [admin-agents]
      summary: Get rate limits for user agent
      security:
        - BearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Rate limits
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/RateLimit'

  /admin/api/userAgents/{userId}/rate-limits/{key}:
    delete:
      tags: [admin-agents]
      summary: Clear specific rate limit for user agent
      security:
        - BearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
        - name: key
          in: path
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Rate limit cleared

  /admin/api/userAgents/{userId}/calls:
    get:
      tags: [admin-calls]
      summary: List all calls for user agent
      security:
        - BearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Calls sorted by date descending
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/CallObject'

  /admin/api/userAgents/{userId}/calls/{groupId}/{callId}:
    get:
      tags: [admin-calls]
      summary: Get specific call details
      security:
        - BearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
        - name: groupId
          in: path
          required: true
          schema:
            type: string
        - name: callId
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Call details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CallObject'

  /admin/api/userAgents/{userId}/calls/{groupId}/{callId}/media/{kind}:
    get:
      tags: [admin-calls]
      summary: Stream call media (mixed audio, video, or audio peaks)
      security:
        - BearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
        - name: groupId
          in: path
          required: true
          schema:
            type: string
        - name: callId
          in: path
          required: true
          schema:
            type: string
        - name: kind
          in: path
          required: true
          schema:
            type: string
            enum: [audio, video, audio-peaks]
      responses:
        '200':
          description: Media stream or audio peaks JSON
          content:
            audio/*:
              schema:
                type: string
                format: binary
            video/*:
              schema:
                type: string
                format: binary
            application/json:
              schema:
                type: object
                properties:
                  peaks:
                    type: array
                    items:
                      type: number

  /admin/api/userAgents/{userId}/calls/{groupId}/{callId}/participants/{demuxIdOrServiceId}/media/{kind}:
    get:
      tags: [admin-calls]
      summary: Stream participant media from a call
      security:
        - BearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
        - name: groupId
          in: path
          required: true
          schema:
            type: string
        - name: callId
          in: path
          required: true
          schema:
            type: string
        - name: demuxIdOrServiceId
          in: path
          required: true
          schema:
            type: string
        - name: kind
          in: path
          required: true
          schema:
            type: string
            enum: [audio, video, audio-peaks]
      responses:
        '200':
          description: Participant media stream or audio peaks
          content:
            audio/*:
              schema:
                type: string
                format: binary
            video/*:
              schema:
                type: string
                format: binary
            application/json:
              schema:
                type: object
                properties:
                  peaks:
                    type: array
                    items:
                      type: number

  # ─────────────────────────────────────────────
  # Service API — /service/api  (service account auth)
  # ─────────────────────────────────────────────

  /service/api/voip/external:route:
    post:
      tags: [service]
      summary: Resolve routing info for an external call
      description: >-
        Resolves the routing for a call event via the VoIP proxy-contact
        assignments, in one of three directions. Inbound — the dialed number is
        a Jamb business number and the caller is external (a call TO a Jamb
        user): the transfer rings the user's personal number presenting the
        assigned proxy, linking a pool proxy to [user, caller] on first contact.
        Outbound — the caller is a Jamb user's personal number dialing a proxy (a
        call FROM a Jamb user): the transfer bridges to the external party the
        proxy represents, presenting the business number; the assignment must
        already exist. Jamb — a call between two Jamb users (each identified by a
        Jamb business number or a plain Jamb account with none): the AI agent
        hosts, no relay agent. The recipient is reached via recipientAci, so
        recipientPhoneNumber is their own personal number. Only the caller ID
        needs a Telnyx number: a JBN caller presents their business number, while
        a bare-account caller is presented by a pool proxy keyed on the
        recipient's business number (a bare account number cannot be an HD-Voice
        caller ID); a bare caller reaching a bare recipient is unroutable. A jamb
        callback (a Jamb user dialing a proxy that resolves to another Jamb user)
        is likewise hosted by the AI agent. For inbound and outbound the relay
        agent for the external party is ensured and started; jamb calls have no
        relay agent.
      security:
        - ServiceAccountAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [callerIdNumber, callerDestinationNumber]
              properties:
                callerIdNumber:
                  type: string
                  description: The calling party's number (E.164)
                callerDestinationNumber:
                  type: string
                  description: The dialed number (E.164)
      responses:
        '200':
          description: Routing info for the call
          content:
            application/json:
              schema:
                type: object
                required: [kind, aiAgentAci, callerAci, callerPhoneNumber, recipientAci, recipientPhoneNumber, numberToDial, callerIdNumber, jambNumber, provider]
                properties:
                  kind:
                    type: string
                    enum: [inbound, outbound, jamb]
                    description: >-
                      Call direction: inbound (an external party dialed a Jamb
                      business number), outbound (a Jamb user dialed an external
                      party, via a proxy or directly), or jamb (a Jamb user
                      dialed another Jamb user's business number).
                  aiAgentAci:
                    type: string
                    description: ACI of the Jamb user's AI agent (the called user for inbound; the caller for outbound; for jamb, the user whose agent already hosts the two users' shared group, falling back to the lower of the two user ACIs when the pair has no group yet — either way the same agent is selected regardless of call direction)
                  callerAci:
                    type: string
                    description: ACI of the calling party — the relay agent representing the external caller (inbound) or the Jamb user (outbound, jamb)
                  callerPhoneNumber:
                    type: string
                    description: Caller ID presented — the assigned proxy number (inbound) or the caller's business number (outbound, jamb); for a jamb caller with a Jamb account but no business number, a pool proxy stands in for them (their own number is not a Telnyx number)
                  recipientAci:
                    type: string
                    description: ACI of the called party — the Jamb user (inbound, jamb) or the relay agent representing the external party (outbound, including a pending org member represented by their relay agent). Never null — the relay-start block fills the relay agent's ACI for outbound legs
                  recipientPhoneNumber:
                    type: string
                    description: Number the bridge rings — the Jamb user's personal number (inbound, jamb) or the external party (outbound). For jamb it is always the recipient's own personal number (the AI agent reaches them via recipientAci), so it agrees with recipientAci's account
                  numberToDial:
                    type: [string, 'null']
                    description: Telnyx number the caller's device places its outbound PSTN leg to — the recipient's business number for jamb (or, when the recipient has a Jamb account but no business number, the pool proxy assigned to [caller, recipient]); the proxy assigned to [user, external party] for outbound, which is the number the user dialed, or — when they dialed the external party directly and the pair had no assignment yet — the pool proxy assigned to it by this request (except a pending org member's outbound leg, which dials the member's business number directly since it is already a Telnyx number fronting their real phone); always null for inbound
                  callerIdNumber:
                    type: string
                    description: Caller's real phone number
                  jambNumber:
                    type: string
                    description: >-
                      The Jamb-owned number this leg matched on — the one whose
                      provider decides the gateway. The number the call arrived
                      at for inbound, the dial target for jamb, and the caller's
                      business number for outbound. Returned alongside
                      `provider` so the routing decision is auditable: a provider
                      with no number beside it cannot be checked when a call
                      lands on the wrong gateway.
                  provider:
                    type: [string, 'null']
                    description: >-
                      Which network to complete the call on. A carrier generally
                      refuses an ANI you do not own at that carrier, so a leg
                      sent through the wrong gateway is a routing failure.

                      One field is enough because the Jamb numbers on a leg
                      cannot disagree: a proxy is allocated at the same provider
                      as the business number it stands in for, and pair lookups
                      resolve inside one provider.

                      NULL IS AN ORDINARY ANSWER and callers MUST treat it as
                      one — it means "we have no record of where this number came
                      from; use the deployment default", never "refuse the call".
                      A number acquired before the inventory existed has no
                      acquisition record, and those calls route today and must
                      keep routing. A plain string rather than an enum for the
                      same reason the inventory listings use one: the provider
                      set is a code change rather than a migration, so a value
                      this build has never heard of must read back rather than
                      fail validation.
        '404':
          description: The call is unroutable — the dialed number is not a Jamb business number and the caller is not a Jamb user's personal number
        '409':
          description: Every pool proxy is already in use by the Jamb user the proxy link is keyed on — the recipient for inbound/jamb, the caller for outbound (pool exhausted)

  /service/api/voip/{phoneNumber}:
    put:
      tags: [service]
      summary: Get or create external phone number relay agent
      security:
        - ServiceAccountAuth: []
      parameters:
        - name: phoneNumber
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Relay agent info
          content:
            application/json:
              schema:
                type: object
                properties:
                  aci:
                    type: string
                  username:
                    type: string

  /service/api/agents/{agentId}:wake:
    post:
      tags: [service]
      summary: Wake (start) an agent by ACI
      security:
        - ServiceAccountAuth: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: string
          description: Agent ACI to wake
      responses:
        '204':
          description: Agent started
        '404':
          description: No agent holds this ACI (it was deleted, or never existed)

  /service/api/messages/send:
    post:
      tags: [service]
      summary: Send a Signal message to a group
      security:
        - ServiceAccountAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [serviceId, text]
              properties:
                serviceId:
                  type: string
                  description: The service account ID whose agent will send the message
                groupId:
                  type: string
                  description: The Signal group ID to send the message to. If omitted, the service account's default group is used.
                text:
                  type: string
                  description: The message body (max 2048 characters)
                  maxLength: 2048
      responses:
        '202':
          description: Message accepted for delivery (async)
          content:
            application/json:
              schema:
                type: object
                properties:
                  groupId:
                    type: string
                    description: The resolved Signal group ID the message was sent to
        '400':
          description: Missing or invalid request fields
        '422':
          description: groupId omitted and no default group is configured for the service account
        '503':
          description: Agent did not connect within the timeout window

  # ─────────────────────────────────────────────
  # Service API — /service/api/orgs/{orgId}/answering-services
  #
  # The same answering-services surface as /api/answering-services, mounted on the
  # service-account anchor and backed by the same implementation
  # (src/server/routes/answering-services.ts), so the two cannot drift.
  #
  # The anchors differ only in how a request names the org it acts on: the /api
  # anchor resolves it from the authenticated Signal caller, while a
  # service-account request — which has no Signal caller — names it in the path.
  # The shared secret is the only gate (as on every /service/api route); the
  # org-creator allowlist and active-admin checks are Signal-caller concepts and do
  # not apply. Org scoping below the route is unchanged: a service belonging to
  # another org is reported as 404, never 403.
  # ─────────────────────────────────────────────

  /service/api/orgs/{orgId}/answering-services:
    parameters:
      - name: orgId
        in: path
        required: true
        description: The org whose answering services this request acts on
        schema:
          type: string
          format: uuid
    get:
      tags: [service]
      summary: List an org's answering services
      description: The org's live answering services, newest first.
      security:
        - ServiceAccountAuth: []
      parameters:
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: count
          in: query
          required: false
          description: Page size; capped at 200 to match the AI server's instruction-list cap
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        '200':
          description: The org's answering services (an empty array when it has none)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/AnsweringService'
        '400':
          description: Invalid orgId, offset or count
        '401':
          description: Missing or invalid service-account credentials
    post:
      tags: [service]
      summary: Create an answering service (rejected on the service anchor)
      description: >
        Provisioning an answering service is a Signal-user operation: every service
        records an owner (the acting caller's internal users.id), and a
        service-account request has no Signal caller to attribute one to. A create
        on this anchor is therefore rejected with 400 BEFORE any Telnyx or AI work —
        it can never half-provision a number. Use the /api anchor to create; the
        service anchor's role is read and lookup.
      security:
        - ServiceAccountAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateAnsweringService'
      responses:
        '400':
          description: >
            A create on the service anchor has no acting user to record as owner and
            is always rejected. (Also covers an invalid orgId or body.)
        '401':
          description: Missing or invalid service-account credentials

  /service/api/orgs/{orgId}/answering-services/{id}:
    parameters:
      - name: orgId
        in: path
        required: true
        description: The org whose answering services this request acts on
        schema:
          type: string
          format: uuid
      - name: id
        in: path
        required: true
        description: Opaque service identifier, as returned by create and list
        schema:
          type: string
          minLength: 1
    get:
      tags: [service]
      summary: Get an answering service
      security:
        - ServiceAccountAuth: []
      responses:
        '200':
          description: The answering service
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnsweringService'
        '400':
          description: Invalid orgId
        '401':
          description: Missing or invalid service-account credentials
        '404':
          description: No such answering service in this org
    put:
      tags: [service]
      summary: Update an answering service
      description: >
        Shallow partial update. Changing the number provisions the new one,
        re-points the instruction, then releases the old one; changing the user
        group re-points the AI's team.
      security:
        - ServiceAccountAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateAnsweringService'
      responses:
        '200':
          description: The updated answering service
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnsweringService'
        '400':
          description: Invalid orgId or body, or the user group is not a live group of this org
        '401':
          description: Missing or invalid service-account credentials
        '404':
          description: No such answering service in this org
        '409':
          description: >
            The new phone number is unavailable or already in use, or the service
            was modified concurrently (this update was decided from row state
            another writer has since changed) — retry
        '503':
          description: Telnyx or the AI server failed; the service is unchanged
    delete:
      tags: [service]
      summary: Delete an answering service
      description: >
        Soft-deletes the service, then best-effort deletes the AI-server
        instruction and releases the Telnyx number. A failure in either external
        system is logged and does not fail the request.
      security:
        - ServiceAccountAuth: []
      responses:
        '204':
          description: Answering service deleted
        '400':
          description: Invalid orgId
        '401':
          description: Missing or invalid service-account credentials
        '404':
          description: >
            No such answering service in this org — including the loser of two
            concurrent deletes, since the winner owns the teardown

  # ─────────────────────────────────────────────
  # Service API — /service/api/answering-services/phoneNumbers/{phoneNumber}
  #
  # Phone-number lookup — service-account ONLY (there is no /api twin). It takes no
  # org: a phone number identifies at most one live answering service globally, so
  # it cannot be org-scoped the way the CRUD surface is, and it must not be reachable
  # by a Signal user probing numbers across orgs. Mounted at its own top-level path,
  # separate from /orgs/{orgId}/answering-services.
  # ─────────────────────────────────────────────

  /service/api/answering-services/phoneNumbers/{phoneNumber}:
    get:
      tags: [service]
      summary: Resolve a phone number to its live answering service
      description: >
        Resolves a phone number to the single live answering service that holds it,
        for trusted internal callers that need the service's identifying details
        (e.g. to attribute an inbound call or message to the right instruction, org
        and team). A soft-deleted service does not match, even if the same number is
        later re-used — only the current live holder is returned.
      security:
        - ServiceAccountAuth: []
      parameters:
        - name: phoneNumber
          in: path
          required: true
          description: >
            The E.164 number, matched against the value the service is stored under.
            The leading `+` may be percent-encoded (`%2B`); Express decodes it before
            matching, so it resolves identically to a literal `+`.
          schema:
            type: string
      responses:
        '200':
          description: The answering service that holds the number
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnsweringServiceDetail'
        '400':
          description: The phone number is not a valid E.164 value
        '401':
          description: Missing or invalid service-account credentials
        '404':
          description: No live answering service holds this number
        '503':
          description: The org database failed

  # ─────────────────────────────────────────────
  # Admin API — /admin/api/remoteConfig
  # ─────────────────────────────────────────────

  /admin/api/remoteConfig:
    get:
      tags: [admin-remote-config]
      summary: List all remote config entries
      security:
        - BearerAuth: []
      responses:
        '200':
          description: All remote config entries
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/RemoteConfig'

    post:
      tags: [admin-remote-config]
      summary: Batch upsert remote config entries
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items:
                allOf:
                  - $ref: '#/components/schemas/RemoteConfig'
                  - required: [name, percentage]
      responses:
        '204':
          description: Entries upserted
        '400':
          description: Invalid request body

  /admin/api/remoteConfig/{name}:
    delete:
      tags: [admin-remote-config]
      summary: Delete a remote config entry by name
      security:
        - BearerAuth: []
      parameters:
        - name: name
          in: path
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Entry deleted

  # ─────────────────────────────────────────────
  # Admin API — /admin/api/dynamicConfig
  # ─────────────────────────────────────────────

  /admin/api/dynamicConfig:
    get:
      tags: [admin-dynamic-config]
      summary: Get the current dynamic configuration
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Current dynamic configuration
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DynamicConfiguration'

    post:
      tags: [admin-dynamic-config]
      summary: Update the dynamic configuration
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DynamicConfiguration'
      responses:
        '204':
          description: Configuration updated
        '400':
          description: Invalid request body

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

  /github/webhook:
    post:
      tags: [webhooks]
      summary: GitHub webhook — triggers agent upgrades on successful Docker builds
      security: []
      parameters:
        - name: x-github-event
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: GitHub webhook payload
      responses:
        '200':
          description: Accepted (processing is async)

  /pubsub/agent-wake:
    post:
      tags: [webhooks]
      summary: GCP Pub/Sub push delivery — agent wake messages
      security: []
      parameters:
        - name: auth
          in: query
          required: true
          schema:
            type: string
          description: Shared secret token (AGENT_WAKE_PUSH_TOKEN)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [message, subscription]
              properties:
                message:
                  type: object
                  required: [data, messageId, publishTime]
                  properties:
                    data:
                      type: string
                      description: Base64-encoded AgentWakeMessage JSON
                    messageId:
                      type: string
                    publishTime:
                      type: string
                      format: date-time
                subscription:
                  type: string
      responses:
        '200':
          description: Message processed and acknowledged
        '500':
          description: Processing failed — Pub/Sub will retry

  /telnyx/voice:
    post:
      tags: [webhooks]
      summary: Telnyx webhook — voice call events
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                data:
                  type: object
                  properties:
                    event_type:
                      type: string
                      enum:
                        - call.initiated
                        - call.answered
                        - call.hangup
                        - streaming.failed
                    payload:
                      type: object
      responses:
        '200':
          description: Accepted (processing is async)
