openapi: 3.1.0
info:
  title: Bizby Team Administration API
  version: "1.0.0"
  description: >
    Automate user lifecycle in Bizby from the directory that owns it: create a
    member, deactivate one, move someone between sub-teams, change a licence
    level, and read seat usage.

    Available to enterprise teams and included in the enterprise subscription.
    There is no additional licence, add-on or per-call charge.

    Every change writes an enterprise audit entry attributed to the key that
    made it, so a directory-driven change is distinguishable from a person
    acting in the dashboard.
  contact:
    name: Bizby
    email: support@bizby.ca
    url: https://bizby.ca/docs/admin-api
  license:
    name: Covered by the Bizby Master Service Agreement
    url: https://bizby.ca/docs/msa

servers:
  - url: https://northamerica-northeast1-bizby-prod-ca-nane1.cloudfunctions.net/adminApi
    description: Production, hosted in Montreal, Canada

security:
  - teamApiKey: []

tags:
  - name: Members
    description: The user lifecycle operations.
  - name: Licensing
    description: Seat and licence state.
  - name: Audit
    description: The append-only audit trail, and the feed a SIEM polls.
  - name: Reports
    description: Adoption, board, sharing and housekeeping reports.

paths:
  /v1/users:
    get:
      tags: [Members]
      summary: List members and pending invitations
      description: >
        Pending invitations are included because they hold a seat: a directory
        reconcile must not provision the same person twice.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
      responses:
        "200":
          description: Current membership.
          content:
            application/json:
              schema:
                type: object
                properties:
                  members:
                    type: array
                    items: { $ref: "#/components/schemas/Member" }
                  pendingInvites:
                    type: array
                    items: { $ref: "#/components/schemas/PendingInvite" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Members]
      summary: Provision a member by email
      description: >
        Creates a pending invitation and reserves a seat immediately. Idempotent:
        provisioning an existing member or an already-invited address returns the
        existing record and consumes no further seat.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                subTeamIds:
                  type: array
                  items: { type: string }
                  description: >
                    Sub-teams to place the person in when they accept. Ids come
                    from GET /v1/subteams. An unknown id returns 400 rather than
                    being ignored.
      responses:
        "201":
          description: Invitation created and a seat reserved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PendingInvite" }
        "200":
          description: Already a member, or already invited. No seat consumed.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: The change would exceed the purchased seat count. Per-seat licences only; an organization licence never refuses for seats.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/users/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Members]
      summary: Read one member
      responses:
        "200":
          description: The member.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Member" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Members]
      summary: Change the active sub-team or the licence level
      description: >
        `license` is the licence level, which is what the seat costs.

        `subTeamId` sets the member's ACTIVE sub-team, which is the one they are
        currently working in. It does not create or remove a membership, and the
        person must already be a member of that sub-team or the request is
        refused with 409: a pointer with no membership behind it is read by the
        product as no sub-team at all. Membership is
        PUT and DELETE /v1/subteams/{subTeamId}/members/{userId}.

        Both fields are validated before anything is written, so a request
        carrying one acceptable change and one unacceptable one applies neither.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                subTeamId:
                  type: string
                  description: The active sub-team. The member must already belong to it.
                license: { type: string, enum: [Full, Free] }
      responses:
        "200":
          description: The member as it now stands.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Member" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: >
            The member does not belong to the requested sub-team. Add the
            membership first with
            PUT /v1/subteams/{subTeamId}/members/{userId}.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      tags: [Members]
      summary: Deactivate a member
      description: >
        Clears pending invitations, transfers boards and workspaces to the team
        owner, removes sub-team memberships, releases the paid seat and
        deprovisions the session. Runs the same code path as the dashboard.

        Refuses, with 409, to remove the team owner or the last admin of a
        sub-team. Both are cases where proceeding would lose something the
        organization cannot recover on its own.
      responses:
        "200":
          description: The member was deactivated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  status: { type: string, enum: [deactivated] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The team owner, or the last admin of a sub-team.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/subteams:
    get:
      tags: [Members]
      summary: List the sub-teams a member can be placed in
      description: >
        Sub-team ids are generated rather than chosen, so a sync maps its own
        groups to Bizby sub-teams by name and keeps the id. Archived sub-teams
        are excluded.
      responses:
        "200":
          description: The team's active sub-teams, sorted by name.
          content:
            application/json:
              schema:
                type: object
                properties:
                  subTeams:
                    type: array
                    items: { $ref: "#/components/schemas/SubTeam" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/subteams/{subTeamId}/members:
    parameters:
      - name: subTeamId
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Members]
      summary: List the members of a sub-team, with their roles
      description: >
        What a reconcile needs to find people who are in a sub-team and no
        longer in the group that drives it. Sorted by email.
      responses:
        "200":
          description: The sub-team's members.
          content:
            application/json:
              schema:
                type: object
                properties:
                  members:
                    type: array
                    items: { $ref: "#/components/schemas/SubTeamMember" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/subteams/{subTeamId}/members/{userId}:
    parameters:
      - name: subTeamId
        in: path
        required: true
        schema: { type: string }
      - name: userId
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Members]
      summary: Read one sub-team membership and its role
      description: >
        How a sync confirms what it wrote. 404 covers both an unknown sub-team
        and a person who is not a member of it: a caller reconciling state wants
        to know the membership is absent and does not need the two
        distinguished.
      responses:
        "200":
          description: The membership.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SubTeamMember" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Members]
      summary: Add a sub-team membership
      description: >
        Places somebody who is already a member of the enterprise into a
        sub-team. It never creates a user: provisioning a new person is
        POST /v1/users, which raises an invitation.

        Repeating the request is safe. A first placement returns 201; somebody
        already in the sub-team returns 200 with `alreadyMember: true`, and
        their existing role is left exactly as it was. Adding is not the
        operation that changes a role, so a retry cannot silently demote an
        administrator. Use PATCH for that.

        This is the sub-team role, admin or member, meaning who administers the
        sub-team. It is not a board permission, which is per board and set in
        that board's sharing settings, and it is not the licence level, which is
        what a seat costs.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                role:
                  type: string
                  enum: [admin, member]
                  default: member
                  description: The role to give them on this sub-team if they are being added.
      responses:
        "201":
          description: The membership was created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SubTeamMember" }
        "200":
          description: They were already a member. Their role is unchanged.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SubTeamMember" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: >
            No such sub-team on this team, or no such member of it. A user id
            belonging to another team is reported the same way as an unknown
            one.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    patch:
      tags: [Members]
      summary: Assign the sub-team role
      description: >
        Promotes a member to administrator of the sub-team, or demotes an
        administrator to member. Sending the role somebody already holds is not
        an error and writes nothing, so this is safe to repeat.

        The last administrator of a sub-team cannot be demoted, because it would
        leave the sub-team unadministered. Designate somebody else first.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role: { type: string, enum: [admin, member] }
      responses:
        "200":
          description: >
            The membership as it now stands. `changed` is false when the role
            was already the one requested.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SubTeamMember" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The last administrator of the sub-team cannot be demoted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      tags: [Members]
      summary: Remove a sub-team membership
      description: >
        Removes the membership and nothing else. No board is moved, reassigned
        or deleted, and no board permission is changed: a board the person owns
        keeps its owner and its sub-team assignment, and a board shared with
        them directly stays shared.

        So this is not by itself a revocation of access to boards they can
        already open. For that, deactivate the member with DELETE /v1/users/{id},
        which transfers owned boards to the team owner, removes every sub-team
        membership, releases the seat and deprovisions the session.

        Repeating the request is safe: somebody already absent returns 200 with
        `alreadyAbsent: true` rather than 404.
      responses:
        "200":
          description: >
            The outcome. `removed` is false with `alreadyAbsent: true` when
            there was no membership to remove. `boardsUnchanged` is always true
            and is stated so a caller does not infer board revocation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  userId: { type: string }
                  subTeamId: { type: string }
                  removed: { type: boolean, description: The membership record was deleted. }
                  alreadyAbsent: { type: boolean, description: There was no membership record to delete. }
                  designationRevoked:
                    type: boolean
                    description: >-
                      An administrator designation was cleared. This can be true
                      with `removed` false: a person can be named on a sub-team's
                      administrator list with no membership record at all, and
                      clearing that revokes their administrative access even
                      though nothing was deleted. It is audited as
                      `subteam_admin_removed`.
                  boardsUnchanged: { type: boolean, enum: [true] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: >
            The sub-team's last administrator, or the person's only sub-team.
            The last-administrator refusal applies to a designation held without
            a membership too, since clearing it would leave the sub-team
            unadministered.
            Removing somebody from their only sub-team would leave them in the
            enterprise holding none; deactivate them instead if that was meant.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/seats:
    get:
      tags: [Licensing]
      summary: Seat and licence usage
      responses:
        "200":
          description: What the team is entitled to and what it is using.
          content:
            application/json:
              schema:
                type: object
                properties:
                  activeMembers: { type: integer }
                  pendingInvites: { type: integer }
                  used: { type: integer }
                  purchased: { type: [integer, "null"] }
                  enforcement: { type: string, enum: [soft, hard] }
                  licenseModel:
                    type: string
                    enum: [per_seat, organization]
                    description: >-
                      per_seat: `purchased` is the cap `enforcement` applies.
                      organization: `purchased` is an estimate; `used` may exceed it and nothing is refused.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/reports/sessions:
    get:
      tags: [Reports]
      summary: Sessions on one board, with how many of those present contributed
      description: >-
        One row per session: the span, the highest number of connected sessions
        during it, and how many distinct authenticated people changed something
        inside it. At most 200 sessions are returned, newest first, and there
        is no paging: `since` is a lower bound only, so moving it forward drops
        older sessions rather than revealing them, and sessions beyond the
        newest 200 in a window cannot currently be read.


        A reconnection does not start a new session. A room that fills again
        within a minute continues the span it had, so a dropped connection is
        reported as one session rather than several short ones.


        `peakConcurrent` is connected sessions at their highest, recorded by the
        board's own room. A person with two tabs open is two sessions, so it is
        an upper bound on people present at once. `contributors` are distinct
        authenticated actors who changed something, and `participation` is one
        divided by the other.


        This is CONTRIBUTION, NOT ATTENDANCE. Nothing in a live session resolves
        a verified identity for a connection, so this is not an attendance
        register and must not be presented as one. Somebody who joined and
        watched appears in `peakConcurrent` and not in `contributors`, which is
        what the ratio is for. A ratio above 1 is meaningful rather than an
        error: it means more people took part across the session than were ever
        in the room at the same moment.


        `contributorsComplete` says whether `contributors` may be read as a
        count. False makes it a floor, for any of three reasons: part of the
        session falls outside the window contribution records are kept for;
        the session predates contributions being tagged with the session they
        belong to, so they cannot be joined to it; or `capturingSessions` is
        below `sessionCount`, meaning somebody in the room was not recording
        and a contribution could have gone unseen. A recorded session is not by
        itself evidence that the work in it was recorded.
      parameters:
        - name: boardId
          in: query
          required: true
          schema: { type: string }
        - name: since
          in: query
          required: false
          description: >-
            ISO-8601 or epoch milliseconds. Defaults to the window contribution
            records are retained for, beyond which the contributor half of every
            row is a floor.
          schema: { type: string }
        - name: format
          in: query
          required: false
          schema: { type: string, enum: [csv] }
      responses:
        "200":
          description: The board's sessions, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  report: { type: string, enum: [sessions] }
                  boardId: { type: string }
                  boardName: { type: [string, "null"] }
                  since: { type: string, format: date-time }
                  generatedAt: { type: string, format: date-time }
                  total: { type: integer }
                  basis:
                    type: string
                    description: States that these are contributions and not attendance.
                  rows:
                    type: array
                    items: { $ref: "#/components/schemas/BoardSession" }
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: >-
            No such board on this team. A board belonging to another team is
            reported the same way, so one tenant cannot confirm a board id
            exists on another.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/reports/{report}:
    get:
      tags: [Reports]
      summary: Administrative report, as JSON or CSV
      description: >-
        The four organisation reports: adoption (`people`), board inventory
        (`boards`), external exposure (`sharing`), and idle or duplicate boards
        (`stale`). `summary` returns the headline counts alone.


        A report is RECOMPUTED from current data and returns EVERY ROW. The
        Insights tab in team settings reads a nightly snapshot with each table
        capped at 500 so the page renders; this is not a page, and the cost of
        completeness is a slower request.


        One limit remains and is reported. The read underneath stops at 20,000
        boards, and when it does `truncatedRead` is true: the figures are then
        based on a subset, `total` is a floor rather than the organisation's
        number, and everything derived from it is short as well. A CSV carries
        no such flag, so read the JSON first if you need to be sure.
      parameters:
        - name: report
          in: path
          required: true
          schema:
            type: string
            enum: [people, boards, sharing, stale, summary]
        - name: format
          in: query
          description: "`csv` returns a downloadable file. Omitted or `json` returns rows."
          schema: { type: string, enum: [json, csv], default: json }
      responses:
        "200":
          description: The report.
          content:
            application/json:
              schema:
                type: object
                properties:
                  report: { type: string }
                  generatedAt: { type: string, format: date-time }
                  total:
                    type: integer
                    description: >-
                      The organisation's total for this report. It matches the
                      length of `rows`, which is every row, unless
                      `truncatedRead` is true, in which case both are a floor.
                  rows: { type: array, items: { type: object, additionalProperties: true } }
                  truncatedRead:
                    type: boolean
                    description: >-
                      True when the organisation holds more boards than one pass
                      covers, so the figures are based on a subset. Stated
                      rather than applied silently.
            text/csv:
              schema: { type: string }
        "404":
          description: Unknown report name. An unrecognised name is refused rather than answered with an empty file.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/audit:
    get:
      tags: [Audit]
      summary: Audit event feed, resumable, for an external SIEM
      description: >-
        A cursor feed over the append-only audit trail, for a SIEM to poll
        unattended.


        The feed deliberately runs behind real time by `lagSeconds` and never
        returns an event newer than `watermark`. Events are written by more than
        one server, each with its own clock, and a trigger can be delivered late,
        so an event stamped earlier can land in the store after one stamped
        later. The lag window gives a record still in flight a bounded time to
        arrive before the watermark passes its timestamp. Without it a consumer
        would skip that record and nothing would report the gap.


        Send `nextCursor` back verbatim. It encodes a position over timestamp AND
        record identity, so two events sharing a millisecond are both delivered.
        While `hasMore` is true, poll again immediately rather than waiting for
        the next interval.


        A page that delivers records writes an `export.audit_exported` event
        attributed to the key that read them. Empty pages are not recorded.


        Worked Microsoft Sentinel ingestion pattern:
        https://bizby.ca/docs/audit-log-siem
      parameters:
        - name: since
          in: query
          description: Lower bound on event time, ISO-8601 or epoch milliseconds. Defaults to 24 hours ago. Ignored when `cursor` is present.
          schema: { type: string }
        - name: until
          in: query
          description: Exclusive upper bound, ISO-8601 or epoch milliseconds.
          schema: { type: string }
        - name: category
          in: query
          schema:
            type: string
            enum: [auth, access, sharing, governance, config, export, recovery]
        - name: limit
          in: query
          description: Records per page.
          schema: { type: integer, default: 200, maximum: 1000, minimum: 1 }
        - name: cursor
          in: query
          description: Resume token from the previous response. Opaque; send it back unchanged.
          schema: { type: string }
        - name: format
          in: query
          description: >-
            `ndjson` returns one record per line and moves the paging fields to
            the `X-Bizby-Next-Cursor`, `X-Bizby-Has-More` and `X-Bizby-Watermark`
            response headers.
          schema: { type: string, enum: [json, ndjson], default: json }
      responses:
        "200":
          description: One page of events, oldest first.
          content:
            application/json:
              schema:
                type: object
                required: [events, hasMore, nextCursor, watermark, lagSeconds]
                properties:
                  events:
                    type: array
                    items: { $ref: "#/components/schemas/AuditEvent" }
                  hasMore:
                    type: boolean
                    description: Another page is already available. Poll again immediately.
                  nextCursor:
                    type: string
                    description: Send this back on the next poll. Advances even on an empty page.
                  watermark:
                    type: string
                    format: date-time
                    description: No event at or after this instant is delivered yet.
                  lagSeconds:
                    type: number
                    description: How far behind real time the watermark deliberately runs.
            application/x-ndjson:
              schema: { type: string }
        "400":
          description: An unreadable cursor, an unknown category, or an unparseable time. A cursor is refused rather than silently restarting, which would replay the whole retained history.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

components:
  securitySchemes:
    teamApiKey:
      type: http
      scheme: bearer
      description: >
        A team API key, issued by the team owner in team settings, in the form
        `keyId.secret`. Scoped to one team. The secret is shown once at creation
        and stored only as a hash.

  schemas:
    AuditEvent:
      type: object
      description: >-
        One record from the append-only trail, as stored. `at` is the writing
        server's clock and `recordedAt` is the store's own; when they disagree,
        the disagreement is itself evidence.
      properties:
        id: { type: string, description: The record's identity, derived from what the event is rather than when it was sent. }
        schemaVersion: { type: integer, enum: [1] }
        at: { type: string, format: date-time }
        recordedAt: { type: string, format: date-time }
        action: { type: string, example: sharing.board_made_public }
        category:
          type: string
          enum: [auth, access, sharing, governance, config, export, recovery]
        outcome: { type: string, enum: [success, failure, denied] }
        actor:
          type: object
          description: Established by the server that observed the act, never taken from a request body.
          properties:
            id: { type: string }
            type: { type: string, enum: [user, guest, service, system] }
            email: { type: string, format: email }
            displayName: { type: string }
        teamId: { type: [string, "null"] }
        subTeamId: { type: [string, "null"] }
        target:
          type: object
          properties:
            type:
              type: string
              enum: [board, team, sub_team, user, invite, share_link, restore_point, setting, export]
            id: { type: string }
            name: { type: string, description: The target's name at the time of the event, so the record still reads later. }
        correlationId: { type: string, description: One per request, so an act and the writes it caused can be tied together. }
        source: { type: string, enum: [worker, functions, admin] }
        reason: { type: string, description: Why it failed or was refused. Absent on success. }
        requestIp: { type: string }
        userAgent: { type: string }
        contentHash:
          type: string
          description: >-
            A digest over the record's own content. Re-derive it to prove the
            record was not edited. It does not prove every record still exists,
            which is what streaming the trail into your own SIEM is for.
        metadata: { type: object, additionalProperties: true }
        expiresAt: { type: string, format: date-time }
    Member:
      type: object
      properties:
        id: { type: string }
        email: { type: [string, "null"], format: email }
        name: { type: [string, "null"] }
        status: { type: string, enum: [active, suspended] }
        license: { type: [string, "null"] }
        subTeamId: { type: [string, "null"] }
        createdAt: { type: [string, "null"], format: date-time }
    PendingInvite:
      type: object
      properties:
        id: { type: string }
        inviteId: { type: string }
        email: { type: string, format: email }
        status: { type: string, enum: [pending] }
        subTeamIds:
          type: array
          items: { type: string }
        invitedAt: { type: [string, "null"], format: date-time }
    BoardSession:
      type: object
      description: >-
        One occupancy of a board. Contribution, not attendance: see the endpoint
        description.
      properties:
        boardId: { type: string }
        boardName: { type: [string, "null"] }
        startedAt: { type: string, format: date-time }
        endedAt: { type: string, format: date-time }
        durationMinutes: { type: integer }
        peakConcurrent:
          type: integer
          description: Connected sessions at their highest. Two tabs count twice.
        sessionCount:
          type: integer
          description: Distinct sessions seen across the span; a reconnect is another one.
        capturingSessions:
          type: integer
          description: >-
            How many of those sessions were recording contributions. Equal to
            sessionCount means the contributor figure covers the whole room;
            lower means a contribution could have happened unrecorded.
        contributors:
          type: integer
          description: Distinct authenticated actors who changed something.
        participation:
          type: [number, "null"]
          description: >-
            contributors divided by peakConcurrent. Above 1 when more people took
            part over the session than were present at once. Null when no peak
            was recorded.
        contributorsComplete:
          type: boolean
          description: >-
            Whether contributors may be read as a count. False makes it a floor:
            part of the session falls outside the window contribution records are
            kept for, the session predates contribution tagging, or not every
            session in the room was recording.
    SubTeamMember:
      type: object
      description: >
        A person's membership of one sub-team, and the role they hold on it.
        `role` is the sub-team role, not a board permission and not a licence
        level. `alreadyMember` appears on a PUT and `changed` on a PATCH, so a
        caller can tell a new placement from a repeated one.
      properties:
        userId: { type: string }
        subTeamId: { type: string }
        role:
          type: string
          enum: [admin, member]
          description: >-
            The role the person EFFECTIVELY holds. A sub-team's administrators
            are the union of its administrator list and the members whose record
            says admin, because either grants administrative access, so this
            reports admin when a person is named on that list whatever their
            member record says.
        email: { type: [string, "null"] }
        name: { type: [string, "null"] }
        addedBy:
          type: [string, "null"]
          description: >-
            The uid of the person who added them, or `admin-api:<keyId>` when a
            machine credential did.
        addedAt: { type: [string, "null"], format: date-time }
        alreadyMember: { type: boolean, description: "PUT only. True when no change was made." }
        changed:
          type: boolean
          description: >-
            PATCH only. True when the effective permission moved, which is what
            is audited. The effective role is the union of the member record and
            the sub-team's administrator list, so a uid on that list is an
            administrator whatever the member record says.
        reconciled:
          type: boolean
          description: >-
            PATCH only. True when the permission did not move but the member
            record and the administrator list disagreed and were squared.
    SubTeam:
      type: object
      properties:
        id: { type: string }
        name: { type: [string, "null"] }
        description: { type: [string, "null"] }
        memberCount: { type: [integer, "null"] }
        seatBudget: { type: [integer, "null"] }
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }

  responses:
    BadRequest:
      description: The request was malformed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: No key was presented, or it is unknown or revoked.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: The team is not an active enterprise account.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No such member of this team, or no such route.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
