# Flux Student Directory API: public reference. Generated file; do not edit.
openapi: 3.0.3
info:
  title: Flux Student Directory API
  version: '1.0'
  description: |-
    Programmatic access to a school's directory on Flux Student Directory: the roster, student and guardian detail, private stars, notes and lists, lunch menus, the school calendar, notices, documents, permission slips, polls, sign-ups, organizations (teams, clubs and classes), the staff directory and photos.

    **Every call needs credentials.** There is no anonymous access, and a call acts as one real user of one or more schools: the response contains only what that person may see in the product, and a write succeeds only if that person may make it (a parent manages their own profile, stars, notes and lists and answers polls, sign-ups and permission slips; an Organization Editor also manages the organizations they were granted). Send the user's access token as `Authorization: Bearer <token>` on every request. Credentials for your application are issued by Flux: write to flux-support@fluxstudentdirectory.com to request access before you build.

    School-administration operations (roster management, access and role management, registration allowlist, school settings), push device registration, account deletion and Flux's internal tools are intentionally **not** part of this reference.

    All paths are relative to the server URL. Request and response bodies are JSON unless a route says otherwise. Errors share one shape (`{ error: { code, message } }`); `401` means the token is missing, expired or invalid, `403` that this user may not do this, and `404` that the thing does not exist or is not visible to this user. Treat unknown response fields as additive: new fields can appear without a version change.
  contact:
    name: Flux Student Directory
    email: flux-support@fluxstudentdirectory.com
  license:
    name: Proprietary
    url: https://studentdirectory.app
servers:
  - url: https://studentdirectory.app/api/v1
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Session
    description: The signed-in user, the schools and roles they hold, and post-registration set-up.
  - name: Categories
    description: Per-school content categories shared by Documents and Notices (read-only here).
  - name: Directory
    description: Roster, student detail, map markers and stars.
  - name: Notes
    description: Private notes a guardian keeps on a student. Never shared with other guardians.
  - name: Lists
    description: A user's private custom lists of students and parents.
  - name: Profile
    description: The signed-in user's own contact information and visibility settings, and leaving a school.
  - name: Photos
    description: Student, parent, staff and organization photos, uploaded and downloaded through short-lived signed URLs.
  - name: Menus
    description: Lunch, breakfast and snack menu reads.
  - name: Calendar
    description: School and organization calendar events, repeating-event series and calendar subscriptions.
  - name: Staff Directory
    description: The school staff listing.
  - name: Document Library
    description: Documents and attachments visible to the user, plus authoring for Organization Editors.
  - name: Notices
    description: Time-bounded notices visible to the user, with read markers, plus authoring for Organization Editors.
  - name: Permission Slips
    description: Permission slips addressed to the user and their children, with answers and a printable PDF.
  - name: Features
    description: Per-school feature toggles, time zone and sidebar counts.
  - name: Watch
    description: A compact roster and star payload for companion watch apps.
  - name: Organizations
    description: 'Teams, performing arts, clubs and classes: description, leadership, roster, results, schedule and photos. Reads are open to any school member; writes need an Organization Editor grant.'
  - name: Notifications
    description: Resolve a tapped link to the right school and mark a notification read.
  - name: Polls
    description: Polls visible to the user, with results where the poll allows them, and answering.
  - name: Sign-Ups
    description: Volunteer and donation sign-ups visible to the user, and claiming, editing or withdrawing a response.
paths:
  /me:
    get:
      tags:
        - Session
      summary: Get the current user and their school memberships
      description: 'Returns every membership the caller has (a parent with children at two schools has two), with enough per-membership detail for the client to build its own school switcher and decide what to render. There is no server-side active school: the client keeps its own choice. `is_fully_deactivated` is true when every membership of the account is deactivated; the client should then show an account-deactivated screen instead of the roster.'
      operationId: getMe
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MeResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /registration/confirm:
    post:
      tags:
        - Session
      summary: Complete registration after email confirmation
      operationId: confirmRegistration
      responses:
        '200':
          description: Provisioning complete (or already had been, safe to call twice)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RegistrationConfirmResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not found
      description: Call once after a freshly confirmed sign-up, for example when the user opens the confirmation link in your app. It looks the caller's verified email up in the school invitations (the registration allowlist), sets up the matching school memberships and guardian links, and returns what was set up so the client can show a "here is what we found" confirmation screen.
  /schools/{schoolId}/students:
    get:
      tags:
        - Directory
      summary: List the roster
      description: 'Every non-archived student at the school, sorted by last name, then first name. `q` matches first or last name (partial, case-insensitive); a 4-digit `q` also matches the graduation year exactly, and for admin callers it also searches linked parents'' name, phone and email (it adds nothing for a parent caller). The full result set is always returned: there is no pagination yet, and one may be added in a future minor version without a new API version. `full=true` returns `RosterStudentFull` items instead of `RosterStudent`; they add `latitude`/`longitude`, the caller''s own `note` and full guardian contact (id, email and phone, not just name and relation), so a client can cache the whole roster in one round trip. `full=true` ignores `q` and `graduationYear`.'
      operationId: listStudents
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - name: q
          in: query
          description: Free-text search across name (and, for admins, linked parent contact info). Ignored when `full=true`.
          schema:
            type: string
        - name: graduationYear
          in: query
          description: Exact-match filter. Ignored when `full=true`.
          schema:
            type: integer
        - name: full
          in: query
          description: When `"true"`, returns `RosterStudentFull` items (see above) instead of `RosterStudent`.
          schema:
            type: string
            enum:
              - 'true'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - students
                properties:
                  students:
                    type: array
                    items:
                      oneOf:
                        - $ref: '#/components/schemas/RosterStudent'
                        - $ref: '#/components/schemas/RosterStudentFull'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /schools/{schoolId}/map-markers:
    get:
      tags:
        - Directory
      summary: List geocoded student pins for the map view
      description: 'Every non-archived, geocoded student, except: for a non-admin (parent) caller, a student''s pin is dropped when that student''s `address_visible` is false. The caller''s own linked students are never suppressed, and admins always see every geocoded student. Coordinates are computed by the server, so a client never needs to geocode. Supplying `org`, `list` or `starred` narrows the response to that one group''s students instead of the whole school; an unknown or other-school organization, an archived organization for a non-admin, or another user''s list is a `404`. The visibility rule above still applies within the scoped set, so scoping never reveals a pin the caller could not otherwise see. Supplying none of the three returns the whole-school response.'
      operationId: listMapMarkers
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - name: org
          in: query
          required: false
          description: Organization `short_name`; narrows the response to that org's roster.
          schema:
            type: string
        - name: list
          in: query
          required: false
          description: Saved list id; narrows the response to that list's members. Owner-scoped.
          schema:
            type: string
            format: uuid
        - name: starred
          in: query
          required: false
          description: Any non-empty value narrows the response to the caller's starred students.
          schema:
            type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - markers
                properties:
                  markers:
                    type: array
                    items:
                      $ref: '#/components/schemas/MapMarker'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /students/{studentId}:
    get:
      tags:
        - Directory
      summary: Get student detail
      description: |-
        `notes` is always scoped to **the caller's own** notes on this student — never another guardian's or an admin's.

        If the student has no coordinates yet (`latitude`/`longitude` both null) but has a full address, this endpoint geocodes on the caller's behalf inline and returns the freshly computed coordinates in the same response — the client should not attempt this itself.
      operationId: getStudent
      parameters:
        - $ref: '#/components/parameters/StudentId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StudentDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /students/{studentId}/star:
    post:
      tags:
        - Directory
      summary: Star a student
      description: Private per-user. Idempotent — starring an already-starred student is a no-op success.
      operationId: starStudent
      parameters:
        - $ref: '#/components/parameters/StudentId'
      responses:
        '204':
          description: Starred
        '401':
          $ref: '#/components/responses/Unauthorized'
    delete:
      tags:
        - Directory
      summary: Unstar a student
      description: Idempotent — unstarring a not-starred student is a no-op success.
      operationId: unstarStudent
      parameters:
        - $ref: '#/components/parameters/StudentId'
      responses:
        '204':
          description: Unstarred
        '401':
          $ref: '#/components/responses/Unauthorized'
  /students/{studentId}/note:
    get:
      tags:
        - Notes
      summary: Get the caller's own note on a student
      description: 'Returns `note: null` if the caller hasn''t left one yet.'
      operationId: getNote
      parameters:
        - $ref: '#/components/parameters/StudentId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - note
                properties:
                  note:
                    nullable: true
                    allOf:
                      - $ref: '#/components/schemas/Note'
                    type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
    put:
      tags:
        - Notes
      summary: Save (create/update/clear) the caller's own note
      description: Upserts the caller's note keyed by (student, author). A blank/empty `body` deletes the note instead of storing an empty row - the response's `note` is `null` in that case.
      operationId: saveNote
      parameters:
        - $ref: '#/components/parameters/StudentId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - body
              properties:
                body:
                  type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - note
                properties:
                  note:
                    nullable: true
                    allOf:
                      - $ref: '#/components/schemas/Note'
                    type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
  /schools/{schoolId}/lists:
    get:
      tags:
        - Lists
      summary: List the caller's own custom lists at a school
      operationId: listLists
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - lists
                properties:
                  lists:
                    type: array
                    items:
                      $ref: '#/components/schemas/StudentList'
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags:
        - Lists
      summary: Create a list
      operationId: createList
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
              properties:
                name:
                  type: string
                description:
                  type: string
                  nullable: true
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StudentList'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /lists/{listId}:
    get:
      tags:
        - Lists
      summary: Get a list and its students
      description: Returns 404 (not 403) if the list doesn't belong to the caller — ownership is not revealed.
      operationId: getList
      parameters:
        - $ref: '#/components/parameters/ListId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StudentListDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags:
        - Lists
      summary: Rename a list
      operationId: updateList
      parameters:
        - $ref: '#/components/parameters/ListId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
              properties:
                name:
                  type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StudentList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
        - Lists
      summary: Delete a list
      operationId: deleteList
      parameters:
        - $ref: '#/components/parameters/ListId'
      responses:
        '204':
          description: Deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /lists/{listId}/students:
    post:
      tags:
        - Lists
      summary: Add a student to a list
      operationId: addStudentToList
      parameters:
        - $ref: '#/components/parameters/ListId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - student_id
              properties:
                student_id:
                  type: string
                  format: uuid
      responses:
        '204':
          description: Added
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /lists/{listId}/students/{studentId}:
    delete:
      tags:
        - Lists
      summary: Remove a student from a list
      operationId: removeStudentFromList
      parameters:
        - $ref: '#/components/parameters/ListId'
        - $ref: '#/components/parameters/StudentId'
      responses:
        '204':
          description: Removed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /lists/{listId}/guardians:
    get:
      tags:
        - Lists
      summary: List a list's individual parent/guardian entries
      description: Owner-only, same as every other list route.
      operationId: listGuardiansOnList
      parameters:
        - $ref: '#/components/parameters/ListId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ListGuardian'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags:
        - Lists
      summary: Add a parent/guardian directly to a list
      description: Not derived from a student being on the list. `linked_student_id` picks which of the parent's students to source an address from for the row.
      operationId: addGuardianToList
      parameters:
        - $ref: '#/components/parameters/ListId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - guardian_membership_id
              properties:
                guardian_membership_id:
                  type: string
                  format: uuid
                linked_student_id:
                  type: string
                  format: uuid
                  nullable: true
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListGuardian'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /lists/{listId}/guardians/{guardianId}:
    delete:
      tags:
        - Lists
      summary: Remove a parent/guardian entry from a list
      operationId: removeGuardianFromList
      parameters:
        - $ref: '#/components/parameters/ListId'
        - $ref: '#/components/parameters/GuardianId'
      responses:
        '204':
          description: Removed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /lists/{listId}/message-recipients:
    get:
      tags:
        - Lists
      summary: Resolved "Message Everyone" recipient list for a list, including phone
      description: The resolved "Message Everyone" recipient list for one of the caller's own lists, including phone numbers (masked by each guardian's privacy settings). Owner only.
      operationId: listListMessageRecipients
      parameters:
        - $ref: '#/components/parameters/ListId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/MessageRecipient'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /lists/{listId}/candidates:
    get:
      tags:
        - Lists
      summary: Minimal candidate list for the "add a parent" picker (list owner only)
      description: Mirrors GET /organizations/{organizationId}/candidates - a parent adding another parent to their own list needs to search among every parent at the school, the same "any school member, not just my own" scope adding a student already has.
      operationId: listListCandidates
      parameters:
        - $ref: '#/components/parameters/ListId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/OrganizationCandidate'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /me/contact:
    get:
      tags:
        - Profile
      summary: Get the caller's own parent contact info
      description: There is no separate guardian-profile table — a parent's name/phone is stored on their registration invitation(s), keyed by verified email, and this reads across every school/ student the caller is linked to, picking the most recently created row that actually has data filled in.
      operationId: getMyContact
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MyContact'
        '401':
          $ref: '#/components/responses/Unauthorized'
    patch:
      tags:
        - Profile
      summary: Update the caller's own parent contact info
      operationId: updateMyContact
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - first_name
                - last_name
                - phone
              properties:
                first_name:
                  type: string
                last_name:
                  type: string
                phone:
                  type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MyContact'
        '401':
          $ref: '#/components/responses/Unauthorized'
      description: Updates the caller's own parent contact information. The change applies to every school and student the parent is linked to at once, because a parent has one identity across schools, matched by their verified email.
  /me/profile-visibility:
    patch:
      tags:
        - Profile
      summary: Set the caller's own contact visibility
      description: 'Sets whether the caller''s contact details (email and phone) are visible to other parents, across every school at once. `full` makes them visible and `name_only` hides them. Home-address visibility is a separate control: see `PATCH /me/address-visibility`.'
      operationId: updateMyProfileVisibility
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - profile_visibility
              properties:
                profile_visibility:
                  type: string
                  enum:
                    - full
                    - name_only
      responses:
        '204':
          description: Updated
        '401':
          $ref: '#/components/responses/Unauthorized'
  /me/address-visibility:
    patch:
      tags:
        - Profile
      summary: Set the caller's own home-address visibility
      description: Sets whether the caller's family home address is visible to other parents. It applies to every student the caller is a linked guardian of. Admins and the family's own linked guardians always see the address. A caller with no linked students gets a success with no effect.
      operationId: updateMyAddressVisibility
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - address_visible
              properties:
                address_visible:
                  type: boolean
      responses:
        '204':
          description: Updated
        '400':
          description: address_visible must be a boolean
        '401':
          $ref: '#/components/responses/Unauthorized'
  /me/leave-school/{membershipId}:
    post:
      tags:
        - Profile
      summary: Leave a school immediately (no grace period)
      description: Self-service, any role. Blocked with `409` if the caller is the last Senior Admin of that school (a school can never be left without one). Deletes the caller's own notes, stars, guardian links and list memberships for that school and removes the membership immediately, with no grace period.
      operationId: leaveSchool
      parameters:
        - name: membershipId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Left
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: Caller is the school's only senior_admin
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /students/{studentId}/photo/upload-url:
    post:
      tags:
        - Photos
      summary: Get a pre-signed upload URL for a student's photo
      description: The returned `photo_key` is deterministic (`student-photos/{studentId}.{ext}`) — re-uploading overwrites the previous photo rather than orphaning it, matching the existing convention. Max 5 MB, `image/jpeg`, `image/png`, `image/webp`, or `image/gif` only (enforced both by the upload-URL's constraints and re-validated when the client calls the confirm step below).
      operationId: getPhotoUploadUrl
      parameters:
        - $ref: '#/components/parameters/StudentId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - content_type
              properties:
                content_type:
                  type: string
                  enum:
                    - image/jpeg
                    - image/png
                    - image/webp
                    - image/gif
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PhotoUploadUrl'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /students/{studentId}/photo:
    post:
      tags:
        - Photos
      summary: Confirm a completed photo upload
      description: Call after successfully `PUT`-ing bytes to the URL from `upload-url` above.
      operationId: confirmPhotoUpload
      parameters:
        - $ref: '#/components/parameters/StudentId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - photo_key
              properties:
                photo_key:
                  type: string
      responses:
        '200':
          description: OK
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    delete:
      tags:
        - Photos
      summary: Remove a student's photo
      operationId: deleteStudentPhoto
      parameters:
        - $ref: '#/components/parameters/StudentId'
      responses:
        '204':
          description: Removed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /students/{studentId}/photo/download-url:
    get:
      tags:
        - Photos
      summary: Get a short-lived signed URL to view a student's photo
      description: Clients must not cache or share this URL, and should re-request it each time a photo needs to be displayed rather than persisting it.
      operationId: getPhotoDownloadUrl
      parameters:
        - $ref: '#/components/parameters/StudentId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PhotoDownloadUrl'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Student has no photo
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /parents/{parentId}/photo/download-url:
    get:
      tags:
        - Photos
      summary: Get a short-lived signed URL to view a parent/guardian's photo
      description: Same 5-minute TTL signed GET URL shape as a student's own photo.
      operationId: getParentPhotoDownloadUrl
      parameters:
        - name: parentId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PhotoDownloadUrl'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Parent has no photo
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /schools/{schoolId}/menus:
    get:
      tags:
        - Menus
      summary: List every menu entry for the school
      description: Every menu entry for the school across all dates, for caching. The client filters to the visible week or day locally. 404s if the school has the `lunch_menus` feature toggle turned off.
      operationId: getMenus
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - menus
                properties:
                  menus:
                    type: array
                    items:
                      $ref: '#/components/schemas/MenuEntry'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The `lunch_menus` feature is toggled off for this school.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /schools/{schoolId}/menus/week:
    get:
      tags:
        - Menus
      summary: Get one week's menus
      description: '`start` must be a Sunday (ISO date); the response covers that date through six days later inclusive. 404s if the school has the `lunch_menus` feature toggle turned off.'
      operationId: getMenuWeek
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - name: start
          in: query
          required: true
          description: ISO date, must fall on a Sunday.
          schema:
            type: string
            format: date
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - menus
                properties:
                  menus:
                    type: array
                    items:
                      $ref: '#/components/schemas/MenuEntry'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The `lunch_menus` feature is toggled off for this school.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /schools/{schoolId}/menus/day:
    get:
      tags:
        - Menus
      summary: Get one day's menus (all meal types)
      description: 404s if the school has the `lunch_menus` feature toggle turned off.
      operationId: getMenuDay
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - name: date
          in: query
          required: true
          schema:
            type: string
            format: date
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - menus
                properties:
                  menus:
                    type: array
                    items:
                      $ref: '#/components/schemas/MenuEntry'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The `lunch_menus` feature is toggled off for this school.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /schools/{schoolId}/menus/{menuId}/image:
    post:
      tags:
        - Menus
      summary: Get (lazily generating if needed) or force-regenerate a lunch entry's AI image
      description: 'Two callers hit this same route: the plain (no `force`) path is open to any authenticated member of the school (parent or admin) — it generates only when there''s no image yet, or the entry''s current items text no longer matches the hash the last generation used, otherwise it returns the existing `image_url` immediately with no regeneration. `?force=true` always regenerates regardless of current state and is restricted to an admin (junior or senior) — this is the client''s "Regenerate Image" button. Only valid for `meal_type: lunch` entries. 404s if the school has the `lunch_menus` feature toggle turned off.'
      operationId: generateMenuImage
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - name: menuId
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: force
          in: query
          required: false
          description: Always regenerate, even if an image already exists and its hash still matches. Admin only.
          schema:
            type: boolean
            default: false
      responses:
        '200':
          description: OK — the (possibly freshly-generated, possibly already-existing) image.
          content:
            application/json:
              schema:
                type: object
                required:
                  - image_url
                  - image_generated_at
                properties:
                  image_url:
                    type: string
                    format: uri
                  image_generated_at:
                    type: string
                    format: date-time
                    nullable: true
        '400':
          description: The entry's `meal_type` is not `lunch`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Not a member of this school, or `force=true` was used by a non-admin.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Menu entry not found, or the `lunch_menus` feature is toggled off for this school.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '502':
          description: Image generation failed (provider error, rate limit, or content-safety rejection). Never happens as a side effect of saving/importing a menu — this route is only ever called after a menu entry already exists.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /schools/{schoolId}/calendar:
    get:
      tags:
        - Calendar
      summary: List every calendar event for the school
      description: Every calendar event for the school across all dates, for caching. The client filters to the visible day, week or month locally; there is no date-range parameter in this API version. 404s if the school has the `calendar` feature toggle turned off.
      operationId: getCalendar
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - events
                properties:
                  events:
                    type: array
                    items:
                      $ref: '#/components/schemas/CalendarEvent'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The `calendar` feature is toggled off for this school.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /schools/{schoolId}/categories:
    get:
      tags:
        - Categories
      summary: List the school's content categories
      description: The per-school category vocabulary shared by Documents and Notices, in the order a Senior Admin arranged them. Any school member may read (parents need names/colors to render pills and filter chips). Not feature-gated.
      operationId: listCategories
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - categories
                properties:
                  categories:
                    type: array
                    items:
                      $ref: '#/components/schemas/Category'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /schools/{schoolId}/calendar/subscriptions:
    post:
      tags:
        - Calendar
      summary: Create a standing.ics calendar subscription on the caller's behalf
      description: Creates a standing `.ics` calendar subscription for the calling user, returning an unguessable `token` that a calendar app can poll at `https://studentdirectory.app/api/calendar/subscribe/{token}/feed.ics`. A set-and-forget alternative to inserting events directly. `categories` and `tags` are inclusion filters (empty means the full calendar); `organizationId`, if given, scopes the feed to that organization's events. 404s if the school has the `calendar` feature toggle turned off.
      operationId: createCalendarSubscription
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                categories:
                  type: array
                  items:
                    type: string
                tags:
                  type: array
                  items:
                    type: string
                organizationId:
                  type: string
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                required:
                  - subscription
                properties:
                  subscription:
                    type: object
                    required:
                      - id
                      - name
                      - token
                      - organization_id
                      - categories
                      - tags
                      - created_at
                    properties:
                      id:
                        type: string
                      name:
                        type: string
                      token:
                        type: string
                      organization_id:
                        type: string
                        nullable: true
                      categories:
                        type: array
                        items:
                          type: string
                      tags:
                        type: array
                        items:
                          type: string
                      created_at:
                        type: string
                        format: date-time
        '400':
          description: organizationId does not belong to this school.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The `calendar` feature is toggled off for this school.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /schools/{schoolId}/staff:
    get:
      tags:
        - Staff Directory
      summary: List the school's staff directory
      description: Every staff member of the school, visible to any school member. 404s if the school has the `staff_directory` feature toggle turned off.
      operationId: getStaff
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - staff
                properties:
                  staff:
                    type: array
                    items:
                      $ref: '#/components/schemas/StaffMember'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The `staff_directory` feature is toggled off for this school.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /schools/{schoolId}/documents:
    post:
      tags:
        - Document Library
      summary: Create a document
      description: An admin, or an Organization Editor creating for an organization they hold a live grant on (`organization_id`; the grant is verified server side). Attachments are uploaded first through `POST /schools/{schoolId}/attachments` and passed back in `attachments`. 404 when the `document_library` feature is off.
      operationId: createDocument
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DocumentInput'
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                type: object
                required:
                  - id
                properties:
                  id:
                    type: string
                    format: uuid
        '400':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Validation failed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not found
    get:
      tags:
        - Document Library
      summary: List Document Library entries visible to the caller
      description: Every document currently visible to the caller, with the auto-populated "School Details" entry prepended first when present - matching the web `/documents` page exactly, for every caller (admin or parent alike; the unfiltered admin management view has no `/v1` equivalent). 404s if the school has the `document_library` feature toggle turned off.
      operationId: getDocuments
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - documents
                properties:
                  documents:
                    type: array
                    items:
                      $ref: '#/components/schemas/Document'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The `document_library` feature is toggled off for this school.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /schools/{schoolId}/documents/{documentId}/attachments/{attachmentId}/download-url:
    get:
      tags:
        - Document Library
      summary: Get a short-lived signed URL for one Document Library attachment
      description: A `DocumentAttachment.key` is a B2 private-bucket object key, not a downloadable URL by itself - this route signs one, matching the existing student/parent/organization-photo download-url pattern (5-minute TTL, same `PhotoDownloadUrl` response shape). The synthetic "School Details" entry has no attachments and is never a valid `documentId` here.
      operationId: getDocumentAttachmentDownloadUrl
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - $ref: '#/components/parameters/DocumentId'
        - $ref: '#/components/parameters/AttachmentId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PhotoDownloadUrl'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The `document_library` feature is toggled off, the document doesn't exist or isn't visible to the caller, or the attachment id doesn't belong to that document.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /schools/{schoolId}/attachments:
    post:
      tags:
        - Document Library
      summary: Upload an attachment before its Document or Notice exists
      description: |-
        The Bearer-token twin of the web's `POST /api/attachments/upload`: stores one file in the private bucket under the school's key prefix and returns its metadata (`DocumentAttachment`) WITHOUT tying it to any record. The client uploads each file as the user attaches it, then sends the whole `attachments` array with `POST /schools/{schoolId}/documents` (or the notices create), so the new document is created with its attachments and the audience's one-time "new document" alert names a complete document. Save rejects a key that is not this school's own.

        Allowed for a school admin, or a user holding a live Organization Editor grant. Types: PDF, Word, Excel, JPEG, PNG. Maximum 10 MB.
      operationId: uploadAttachment
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
                - file
                - entity_type
              properties:
                file:
                  type: string
                  format: binary
                entity_type:
                  type: string
                  enum:
                    - document
                    - notice
                  description: Selects the storage key prefix.
      responses:
        '201':
          description: Uploaded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentAttachment'
        '400':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Validation failed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '413':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Too large
        '500':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Response
  /schools/{schoolId}/notices:
    post:
      tags:
        - Notices
      summary: Create a notice
      description: An admin, or an Organization Editor creating for an organization they hold a live grant on (`organization_id`). Runs the same create logic as the web notice form (validation, audience, the one-time alert to the audience). 404 when the `notices` feature is off.
      operationId: createNotice
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NoticeInput'
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                type: object
                required:
                  - id
                properties:
                  id:
                    type: string
                    format: uuid
        '400':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Validation failed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not found
    get:
      tags:
        - Notices
      summary: List notices visible to the caller
      description: 'Every notice currently visible to the caller, for admins and parents alike: audience targeting and expiry are already applied, so an expired notice or one addressed to other people is not returned. 404s if the school has the `notices` feature toggle turned off.'
      operationId: getNotices
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - notices
                properties:
                  notices:
                    type: array
                    items:
                      $ref: '#/components/schemas/Notice'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The `notices` feature is toggled off for this school.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /schools/{schoolId}/polls:
    get:
      tags:
        - Polls
      summary: List polls visible to the caller
      description: 'Every poll currently visible to the caller: audience targeting and the rule that a poll is hidden until it opens are already applied. `closed` and `already_responded` are computed per poll so a client can badge it without its own schedule logic. 404s if the school has the `polls` feature toggle turned off.'
      operationId: getPolls
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - polls
                properties:
                  polls:
                    type: array
                    items:
                      $ref: '#/components/schemas/Poll'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The `polls` feature is toggled off for this school.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /polls/{pollId}:
    get:
      tags:
        - Polls
      summary: Get one poll's full detail, including questions/choices and (when visible) results
      description: One poll's full detail, including questions and choices. The route has no school in its path; the poll's school is resolved for you. A poll that does not exist and one the caller cannot see both answer `404`. `results` holds the poll's current results whenever its `results_visibility` mode allows them right now, and is `null` otherwise (for example `never`, or `after_close` before the poll has closed); do not compute results client-side.
      operationId: getPoll
      parameters:
        - $ref: '#/components/parameters/PollId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PollDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /polls/{pollId}/respond:
    post:
      tags:
        - Polls
      summary: Submit answers to a poll
      description: 'Submits the caller''s answers. Every question in the poll must be answered exactly once in the same request (no partial submission). One immutable response is kept per poll and caller: a duplicate submission is rejected, not overwritten. A caller who is not in the poll''s audience gets `not_authorized`, unlike the GET routes, which answer `404` for a poll the caller cannot see.'
      operationId: respondToPoll
      parameters:
        - $ref: '#/components/parameters/PollId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - answers
              properties:
                answers:
                  type: array
                  items:
                    $ref: '#/components/schemas/PollAnswerInput'
      responses:
        '200':
          description: Response recorded
          content:
            application/json:
              schema:
                type: object
                required:
                  - submitted_at
                properties:
                  submitted_at:
                    type: string
                    format: date-time
        '400':
          description: The poll has closed, the answer set fails validation (missing/duplicate/malformed answers, or an answer referencing a question/choice outside this poll), or the caller already responded (including a concurrent double-submit race).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Authenticated, but not a member of this poll's audience.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
  /schools/{schoolId}/signups:
    get:
      tags:
        - Sign-Ups
      summary: List sign-ups visible to the caller
      description: 'Every sign-up currently visible to the caller: audience or organization-membership targeting, the rule that a sign-up is hidden until it opens, and the `visible_until` cutoff are already applied. `closed` and `has_claims` are computed per sign-up so a client can badge it without its own schedule logic. This is a summary view: no items and no claimant names (see `GET /signups/{signupId}`). 404s if the school has the `signups` feature toggle turned off.'
      operationId: getSignups
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - signups
                properties:
                  signups:
                    type: array
                    items:
                      $ref: '#/components/schemas/Signup'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The `signups` feature is toggled off for this school.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /signups/{signupId}:
    patch:
      tags:
        - Sign-Ups
      summary: Edit a sign-up
      description: A full re-submit. Authorization is decided by the sign-up (an admin, or an Organization Editor on one of its organizations). Returns the saved sign-up with its items split into time-slot items (sorted by start) and other items (sorted by position).
      operationId: updateSignup
      parameters:
        - $ref: '#/components/parameters/SignupId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SaveSignupBody'
      responses:
        '200':
          description: The saved sign-up.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  title:
                    type: string
                  description:
                    type: string
                    deprecated: true
                    description: Deprecated plain-text rendering of `description_delta`, kept only for app builds 4 and earlier. Read `description_delta`; this field will be removed.
                  description_delta:
                    type: object
                    nullable: true
                  audience_id:
                    type: string
                    format: uuid
                    nullable: true
                  organization_ids:
                    type: array
                    items:
                      type: string
                      format: uuid
                  opens_at:
                    type: string
                    format: date-time
                    nullable: true
                  closes_at:
                    type: string
                    format: date-time
                    nullable: true
                  visible_until:
                    type: string
                    format: date-time
                  updated_at:
                    type: string
                    format: date-time
                  closed:
                    type: boolean
                  time_slot_items:
                    type: array
                    items:
                      $ref: '#/components/schemas/SignupItemBody'
                  other_items:
                    type: array
                    items:
                      $ref: '#/components/schemas/SignupItemBody'
        '400':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Validation failed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not found
    delete:
      tags:
        - Sign-Ups
      summary: Delete a sign-up
      description: A hard delete; its items, responses and organization links go with it. If the caller is refused the sign-up survives and the response is 403.
      operationId: deleteSignup
      parameters:
        - $ref: '#/components/parameters/SignupId'
      responses:
        '204':
          description: Deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not found
    get:
      tags:
        - Sign-Ups
      summary: Get one sign-up's full detail, including every item's claimant list and remaining capacity
      description: One sign-up's full detail. The route has no school in its path; the sign-up's school is resolved for you. A sign-up that does not exist and one the caller cannot see both answer `404`. Items are split into `time_slot_items` (sorted chronologically) and `other_items` (in the order the organizer chose). Each item's `claims` array lists every current claimant's display name and note, `remaining` is its live capacity, and `my_claim` is the caller's own claim on that item, or `null`. Reading a sign-up marks it as opened by the caller, which clears it from their sidebar sign-up count.
      operationId: getSignup
      parameters:
        - $ref: '#/components/parameters/SignupId'
        - name: prefetch
          in: query
          required: false
          description: Pass `1` for a background cache-warming fetch that is not the user opening the sign-up - skips recording the view, so it does not clear the sidebar badge.
          schema:
            type: string
            enum:
              - '1'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SignupDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /signups/{signupId}/respond:
    post:
      tags:
        - Sign-Ups
      summary: Claim, edit, or withdraw a response to one of this sign-up's items
      description: 'Claims, edits or withdraws the caller''s response on one item of a sign-up, subject to the sign-up being open and the caller being in its audience (a caller outside the audience gets `not_authorized`, unlike the GET routes, which answer `404`). `action: "edit"` with just `item_id` (and an optional `note`) updates the note on the caller''s existing claim in place; adding `to_item_id` moves the claim to a different item of the same sign-up (the new claim is created before the old one is removed, so a full target item leaves the original claim intact). A `to_item_id` equal to `item_id` means no move. The response is intentionally minimal: `item_id`, `claimed`, `note` and `remaining` for the item the claim now lives on. Call `GET /signups/{signupId}` again for the full claimant list.'
      operationId: respondToSignup
      parameters:
        - $ref: '#/components/parameters/SignupId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - action
                - item_id
              properties:
                action:
                  type: string
                  enum:
                    - claim
                    - edit
                    - withdraw
                item_id:
                  type: string
                  format: uuid
                  description: The item being claimed/edited/withdrawn - or, for `edit` with `to_item_id` set, the item being moved FROM.
                to_item_id:
                  type: string
                  format: uuid
                  description: '`edit` only. If present and different from `item_id`, moves the claim to this item instead of just updating its note.'
                note:
                  type: string
                  nullable: true
                  description: Optional free-text note. Ignored for `withdraw`. Trimmed; an empty/whitespace-only string is stored as null.
      responses:
        '200':
          description: Claimed, edited, or withdrawn successfully.
          content:
            application/json:
              schema:
                type: object
                required:
                  - item_id
                  - claimed
                  - note
                  - remaining
                properties:
                  item_id:
                    type: string
                    format: uuid
                    description: The item the caller's claim now lives on (the target item, for a change) - or the item just withdrawn from, for `withdraw`.
                  claimed:
                    type: boolean
                    description: False only after a successful withdraw.
                  note:
                    type: string
                    nullable: true
                  remaining:
                    type: integer
                    nullable: true
                    description: item_id's current remaining capacity, recomputed after this write. Null for an uncapped item.
        '400':
          description: Missing/invalid `action`/`item_id`, the sign-up has closed, `to_item_id` doesn't belong to the same sign-up as `item_id`, the target item is full, the caller already claimed the target item (a concurrent double-claim race), or the caller had no existing claim to edit/withdraw.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Authenticated, but not a member of this sign-up's resolved audience.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
  /schools/{schoolId}/features:
    get:
      tags:
        - Features
      summary: Get the school's current feature toggle state
      description: The school's current feature toggles as a map of feature key to boolean, so a client can hide or disable gated screens up front instead of discovering a disabled feature through a `404`. Open to any school member.
      operationId: getFeatures
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - features
                properties:
                  features:
                    description: 'The `FeatureKey -> boolean` map, plus the EFFECTIVE `classes` and `departments` flags: true only when the Operator made the feature available AND the school turned it on (opt-in at both levels, so absent/false means off - unlike the older keys, where a missing key means on).'
                    allOf:
                      - $ref: '#/components/schemas/FeatureToggles'
                      - type: object
                        properties:
                          classes:
                            type: boolean
                          departments:
                            type: boolean
                  timezone:
                    type: string
                    description: The school's IANA timezone, e.g. `America/New_York`.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /schools/{schoolId}/timezone:
    get:
      tags:
        - Features
      summary: Get the school's IANA timezone
      description: Open to any school member.
      operationId: getSchoolTimezone
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - timezone
                properties:
                  timezone:
                    type: string
                    example: America/New_York
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /organizations/{organizationId}/calendar-series:
    get:
      tags:
        - Calendar
      summary: List an organization's (or class's, or department's) repeating events (admin or that organization's Editor)
      operationId: listOrganizationCalendarSeries
      parameters:
        - name: organizationId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - series
                properties:
                  series:
                    type: array
                    items:
                      $ref: '#/components/schemas/CalendarSeries'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Organization not found or Calendar disabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    post:
      tags:
        - Calendar
      summary: Create a repeating event on an organization's calendar (admin or that organization's Editor)
      operationId: createOrganizationCalendarSeries
      parameters:
        - name: organizationId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CalendarSeriesCreateBody'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                required:
                  - series
                  - generated_count
                properties:
                  series:
                    $ref: '#/components/schemas/CalendarSeries'
                  generated_count:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Organization not found or Calendar disabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: The rule or template is invalid
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /organizations/{organizationId}/calendar-series/preview:
    post:
      tags:
        - Calendar
      summary: Preview a repeating event on an organization's calendar (no writes)
      operationId: previewOrganizationCalendarSeries
      parameters:
        - name: organizationId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CalendarSeriesCreateBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CalendarSeriesPreview'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Organization not found or Calendar disabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: The rule or template is invalid
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /calendar-series/{seriesId}:
    parameters:
      - name: seriesId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      tags:
        - Calendar
      summary: Get a repeating event series (rule, template, summary, counts, flagged instances)
      operationId: getCalendarSeries
      parameters:
        - name: include
          in: query
          required: false
          description: '`occurrences` adds every instance (date order) for the management screen. Admin or the series'' calendar Editor only (403 otherwise).'
          schema:
            type: string
            enum:
              - occurrences
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - series
                properties:
                  series:
                    $ref: '#/components/schemas/CalendarSeries'
                  occurrences:
                    type: array
                    items:
                      type: object
                      required:
                        - event_id
                        - occurrence_date
                        - start_date
                        - state
                      properties:
                        event_id:
                          type: string
                          format: uuid
                        occurrence_date:
                          type: string
                          format: date
                        start_date:
                          type: string
                          format: date
                        state:
                          type: string
                          enum:
                            - generated
                            - edited
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Series not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    patch:
      tags:
        - Calendar
      summary: Edit a series for this event, this and following, or all events
      description: '`scope: this` edits one instance (it becomes `edited`); `following` splits the series at `occurrence_date` and the response is the NEW series; `all` re-diffs every instance. Hand-edited instances are kept (`kept_edited_count`); a deleted instance is never resurrected.'
      operationId: updateCalendarSeries
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - scope
              properties:
                scope:
                  type: string
                  enum:
                    - this
                    - following
                    - all
                occurrence_date:
                  type: string
                  format: date
                fields:
                  type: object
                  additionalProperties: true
                rule:
                  $ref: '#/components/schemas/CalendarSeriesRule'
                skip_closures:
                  type: boolean
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - series
                  - affected_count
                  - kept_edited_count
                properties:
                  series:
                    $ref: '#/components/schemas/CalendarSeries'
                  affected_count:
                    type: integer
                  kept_edited_count:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Series or occurrence not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: Invalid scope, rule or fields
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags:
        - Calendar
      summary: Delete a series for this event, this and following, or all events
      operationId: deleteCalendarSeries
      parameters:
        - name: scope
          in: query
          required: true
          schema:
            type: string
            enum:
              - this
              - following
              - all
        - name: occurrence_date
          in: query
          required: false
          schema:
            type: string
            format: date
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - deleted_count
                  - kept_edited_count
                properties:
                  deleted_count:
                    type: integer
                  kept_edited_count:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Series or occurrence not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: Invalid scope or date
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /schools/{schoolId}/sidebar-counts:
    get:
      tags:
        - Features
      summary: Get the caller's sidebar "needs attention" counts for Polls, Permission Slips and Sign-Ups
      description: 'Per-user counts behind the sidebar badges (zero means show no badge). Every caller, admins included, gets the same per-user semantics as a parent; only the caller''s own state is reported. A feature turned off for the school counts `0` (this route does not `404` on a disabled feature). `polls`: visible polls that are open right now and that the caller has not answered. `permission_slips`: slips for which at least one of the caller''s linked students is targeted and not yet completed, and whose `required_by` date has not passed (counted through the end of that day in the viewer''s zone); a slip counts once however many of the caller''s children still owe it. `signups`: visible sign-ups that are open for responses, that the caller did not create or administer as an Organization Editor, and that the caller has not yet opened (`GET /signups/{signupId}` records the open).'
      operationId: getSidebarCounts
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - name: tz
          in: query
          required: false
          description: IANA time zone of the viewer (e.g. `America/New_York`), used for the permission-slip due-date cut-off. UTC when absent or invalid.
          schema:
            type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - polls
                  - permission_slips
                  - signups
                properties:
                  polls:
                    type: integer
                    minimum: 0
                  permission_slips:
                    type: integer
                    minimum: 0
                  signups:
                    type: integer
                    minimum: 0
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /schools/{schoolId}/watch-payload:
    get:
      tags:
        - Watch
      summary: Get the compact roster + star payload for WatchConnectivity relay
      description: 'A compact roster and star payload for companion watch apps: deliberately minimal fields (no address, no photo, no notes). Star changes made on the watch go back through `POST` and `DELETE /students/{studentId}/star`, called by the paired phone.'
      operationId: getWatchPayload
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WatchPayload'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /schools/{schoolId}/organizations:
    get:
      tags:
        - Organizations
      summary: List the school's organizations
      description: 'Non-archived only by default; `includeArchived=true` is honored for admins only. Optional `category` filter (`athletics`, `performing_arts`, `clubs`, `other`). 404s if the `organizations` feature toggle is off. `kind` selects what is listed: absent or `organization` returns only ordinary organizations; `kind=class` or `kind=department` returns `{ organizations: [ClassOrDepartment] }` and 404s unless that feature is active for the school. A hidden class roster yields only a count and the caller''s own linked children, and a private class is not returned to a parent unless their child is on it.'
      operationId: listOrganizations
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - name: kind
          in: query
          description: organization (default) | class | department
          schema:
            type: string
            enum:
              - organization
              - class
              - department
            default: organization
        - name: category
          in: query
          schema:
            type: string
            enum:
              - athletics
              - performing_arts
              - clubs
              - other
        - name: includeArchived
          in: query
          schema:
            type: boolean
            default: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - organizations
                properties:
                  organizations:
                    type: array
                    items:
                      $ref: '#/components/schemas/OrganizationSummary'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The `organizations` feature is toggled off for this school.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /schools/{schoolId}/organizations/{shortName}:
    get:
      tags:
        - Organizations
      summary: Get an organization's full detail
      description: Core fields, photo keys (not signed URLs - see the Photos routes below), leadership, roster, its filtered calendar events, and `can_edit`/`can_manage` for the caller. An archived organization 404s for a non-admin caller.
      operationId: getOrganization
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - name: shortName
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Organization'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /organizations/{organizationId}:
    patch:
      tags:
        - Organizations
      summary: Update an organization's description (Admin or org editor)
      description: A `name` field in this route's body is silently ignored.
      operationId: updateOrganization
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                description_delta:
                  type: object
                  description: Quill Delta document for the description.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrganizationSummary'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Not an admin or a live org-editor grant for this organization.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
  /organizations/{organizationId}/schedule:
    get:
      tags:
        - Organizations
      summary: A class's weekly meeting patterns
      description: Anyone who can see the class. Each pattern is a weekly repeating series of source `class_schedule` whose events appear on the class calendar. 404 unless the organization is a class and Classes is active.
      operationId: listClassSchedule
      parameters:
        - name: organizationId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - patterns
                properties:
                  patterns:
                    type: array
                    items:
                      $ref: '#/components/schemas/ClassSchedulePattern'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Not a class, or Classes disabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    post:
      tags:
        - Organizations
      summary: Add a meeting pattern to a class (Admin or the class's Editor)
      description: Generates the pattern's events on the class calendar (skipping school closures unless `skip_closures` is false). Weekly only; the date range is normally the class's term. At most 400 meetings.
      operationId: addClassSchedulePattern
      parameters:
        - name: organizationId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ClassSchedulePatternBody'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  pattern:
                    $ref: '#/components/schemas/ClassSchedulePattern'
                  generated_count:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Not a class, or Classes disabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: The pattern is invalid
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /organizations/{organizationId}/schedule/preview:
    post:
      tags:
        - Organizations
      summary: Preview the dates a meeting pattern would generate (no writes)
      operationId: previewClassSchedule
      parameters:
        - name: organizationId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ClassSchedulePatternBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CalendarSeriesPreview'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Not a class, or Classes disabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: The pattern is invalid
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /organizations/{organizationId}/schedule/{seriesId}:
    patch:
      tags:
        - Organizations
      summary: Replace one meeting pattern (regenerates only its own events)
      description: Same body as create. Events someone edited by hand are kept; events added by hand are never touched.
      operationId: updateClassSchedulePattern
      parameters:
        - name: organizationId
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: seriesId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ClassSchedulePatternBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  pattern:
                    $ref: '#/components/schemas/ClassSchedulePattern'
                  affected_count:
                    type: integer
                  kept_edited_count:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: The pattern is invalid
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags:
        - Organizations
      summary: Remove a meeting pattern and the events it generated
      operationId: deleteClassSchedulePattern
      parameters:
        - name: organizationId
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: seriesId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted_count:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /organizations/{organizationId}/end-of-term:
    get:
      tags:
        - Organizations
      summary: A class's term dates, end-of-term plan and latest runs (Admin or the class's Editor)
      description: '`plan` is null when none is saved, and then nothing happens at the end of the term. The plan runs once per term, shortly after `ends_on` has finished in the SCHOOL''s timezone.'
      operationId: getClassEndOfTerm
      parameters:
        - name: organizationId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  organization_id:
                    type: string
                    format: uuid
                  starts_on:
                    type: string
                    format: date
                    nullable: true
                  ends_on:
                    type: string
                    format: date
                    nullable: true
                  lifecycle:
                    type: string
                    enum:
                      - active
                      - awaiting_dates
                      - archived
                  plan:
                    allOf:
                      - $ref: '#/components/schemas/EndOfTermPlan'
                    nullable: true
                    type: object
                  runs:
                    type: array
                    items:
                      $ref: '#/components/schemas/EndOfTermRun'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Not a class, or Classes disabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    put:
      tags:
        - Organizations
      summary: Save (or remove) the end-of-term plan
      description: Body is an object with a `plan` key holding an EndOfTermPlan, or `plan` set to null to remove it. Every key of the plan is required; anything left at `keep` is untouched at the end of the term.
      operationId: saveClassEndOfTermPlan
      parameters:
        - name: organizationId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - plan
              properties:
                plan:
                  allOf:
                    - $ref: '#/components/schemas/EndOfTermPlan'
                  nullable: true
                  type: object
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  plan:
                    allOf:
                      - $ref: '#/components/schemas/EndOfTermPlan'
                    nullable: true
                    type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Not a class, or Classes disabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: The plan is invalid
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /organizations/{organizationId}/end-of-term/preview:
    post:
      tags:
        - Organizations
      summary: Preview what running the saved plan now would change (no writes)
      description: 422 when no plan is saved or the class is archived.
      operationId: previewClassEndOfTerm
      parameters:
        - name: organizationId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EndOfTermResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Not a class, or Classes disabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: No plan saved, or the class is archived
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /organizations/{organizationId}/end-of-term/run:
    post:
      tags:
        - Organizations
      summary: Run the saved end-of-term plan now ("Run reset now")
      description: 'Runs the plan immediately. Idempotent per term (a second call answers `already_ran: true`), recorded in the school audit log and the class''s run history, and the class''s Editors are notified. Not undoable.'
      operationId: runClassEndOfTerm
      parameters:
        - name: organizationId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EndOfTermResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Not a class, or Classes disabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: No plan saved, or the class is archived
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /organizations/{organizationId}/leaders:
    post:
      tags:
        - Organizations
      summary: Add a leadership entry (Admin or org editor)
      description: Either `school_staff_id` (validated against the organization's own school) or a freeform `display_name`/`display_title`. `display_title` doubles as the org-specific role label (e.g. "Head Coach") for both cases.
      operationId: createOrganizationLeader
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                school_staff_id:
                  type: string
                  format: uuid
                  nullable: true
                display_name:
                  type: string
                  nullable: true
                display_title:
                  type: string
                  nullable: true
                display_order:
                  type: integer
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrganizationLeader'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /organizations/{organizationId}/leaders/{leaderId}:
    patch:
      tags:
        - Organizations
      summary: Edit a leadership entry (Admin or org editor)
      operationId: updateOrganizationLeader
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
        - $ref: '#/components/parameters/LeaderId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                school_staff_id:
                  type: string
                  format: uuid
                  nullable: true
                display_name:
                  type: string
                  nullable: true
                display_title:
                  type: string
                  nullable: true
                display_order:
                  type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrganizationLeader'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
        - Organizations
      summary: Remove a leadership entry (Admin or org editor)
      operationId: deleteOrganizationLeader
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
        - $ref: '#/components/parameters/LeaderId'
      responses:
        '204':
          description: No Content
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /organizations/{organizationId}/members:
    get:
      tags:
        - Organizations
      summary: List an organization's parent members
      description: Roster-visible content - any school member can read it, not just admin/editor, matching the leaders/students routes' own visibility.
      operationId: listOrganizationMembers
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/OrganizationParentMember'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags:
        - Organizations
      summary: Add a parent as an organization member
      description: Independent of whether that parent's child is on the org's roster. For a Self-Sign-Up organization's own self-service join (no Editor/Admin grant required), see POST /organizations/{organizationId}/members/self-join instead.
      operationId: addOrganizationMember
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - membership_id
              properties:
                membership_id:
                  type: string
                  format: uuid
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrganizationParentMember'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /organizations/{organizationId}/members/{membershipId}:
    delete:
      tags:
        - Organizations
      summary: Remove an organization member
      description: Self-removal ("leave") is out of scope for this build - a self-joined member is removed the same way any member is, by an Editor or Admin.
      operationId: deleteOrganizationMember
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
        - $ref: '#/components/parameters/MembershipId'
      responses:
        '204':
          description: No Content
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /organizations/{organizationId}/members/self-join:
    post:
      tags:
        - Organizations
      summary: Self-join a Self-Sign-Up organization
      description: 'Any parent at the school can join without Editor or Admin approval, but only when the organization''s `membership_mode` is `self_signup` (fixed when the organization is created). The membership used is always the caller''s own at the organization''s school, never taken from the request body. Idempotent: joining an organization the caller already belongs to returns `204`, not an error.'
      operationId: selfJoinOrganization
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrganizationParentMember'
        '204':
          description: No Content (already a member)
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /organizations/{organizationId}/candidates:
    get:
      tags:
        - Organizations
      summary: Minimal candidate list for the add-member/add-editor picker (Admin or org editor)
      description: Excludes deactivated memberships; returns only id/email/role.
      operationId: listOrganizationCandidates
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/OrganizationCandidate'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /organizations/{organizationId}/message-recipients:
    get:
      tags:
        - Organizations
      summary: Resolved "Message Everyone" recipient list, including phone (Admin or org editor)
      operationId: listOrganizationMessageRecipients
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/MessageRecipient'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
      description: 'The resolved "Message Everyone" recipient list for an organization, including phone numbers: every guardian of every student on the roster (email and phone masked by each guardian''s privacy settings) plus every parent member, deduplicated by email. Requires an Admin or an Editor of the organization.'
  /organizations/{organizationId}/results:
    get:
      tags:
        - Organizations
      summary: List an organization's results
      description: Newest first, then `sort_order`. 404 when the organization's `results` feature is off - the same answer whether or not rows exist.
      operationId: listOrganizationResults
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - results
                properties:
                  results:
                    type: array
                    items:
                      $ref: '#/components/schemas/OrganizationResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags:
        - Organizations
      summary: Add a result (Admin or org editor)
      description: At least one of `title`/`competitor` is required. `calendar_event_id` is optional and must be one of THIS organization's events. No link to any student.
      operationId: createOrganizationResult
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrganizationResultInput'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                required:
                  - id
                properties:
                  id:
                    type: string
                    format: uuid
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /organizations/{organizationId}/results/{resultId}:
    patch:
      tags:
        - Organizations
      summary: Edit a result (Admin or org editor)
      operationId: updateOrganizationResult
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
        - name: resultId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrganizationResultInput'
      responses:
        '200':
          description: The updated result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrganizationResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
        - Organizations
      summary: Delete a result (Admin or org editor)
      operationId: deleteOrganizationResult
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
        - name: resultId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /organizations/{organizationId}/students:
    post:
      tags:
        - Organizations
      summary: Add a student to the roster
      description: Manual add only - no CSV bulk import. `student_id` is validated against the organization's own school.
      operationId: addOrganizationStudent
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - student_id
              properties:
                student_id:
                  type: string
                  format: uuid
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrganizationRosterStudent'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /organizations/{organizationId}/students/{studentId}:
    delete:
      tags:
        - Organizations
      summary: Remove a student from the roster (Admin or org editor)
      operationId: removeOrganizationStudent
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
        - $ref: '#/components/parameters/StudentId'
      responses:
        '204':
          description: No Content
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /organizations/{organizationId}/photos/{slot}/upload-url:
    post:
      tags:
        - Organizations
        - Photos
      summary: Get a presigned direct-to-B2 upload URL for one photo slot (Admin or org editor)
      description: Same pre-signed-PUT-URL pattern as the student-photo routes. `slot` is 1, 2, or 3.
      operationId: createOrganizationPhotoUploadUrl
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
        - $ref: '#/components/parameters/PhotoSlot'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - content_type
                - content_length
              properties:
                content_type:
                  type: string
                  enum:
                    - image/jpeg
                    - image/png
                    - image/webp
                    - image/gif
                content_length:
                  type: integer
                  description: Bytes, max ~2.2 MB.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - upload_url
                  - photo_key
                  - expires_in
                properties:
                  upload_url:
                    type: string
                  photo_key:
                    type: string
                  expires_in:
                    type: integer
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          description: Declared content_length exceeds the server max.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /organizations/{organizationId}/photos/{slot}/download-url:
    get:
      tags:
        - Organizations
        - Photos
      summary: Get a short-lived signed download URL for one photo slot
      description: Open to any school member who can see the organization.
      operationId: getOrganizationPhotoDownloadUrl
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
        - $ref: '#/components/parameters/PhotoSlot'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - download_url
                  - expires_in
                properties:
                  download_url:
                    type: string
                  expires_in:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /organizations/{organizationId}/photos/{slot}:
    post:
      tags:
        - Organizations
        - Photos
      summary: Confirm a photo upload (Admin or org editor)
      description: Called after the client PUTs bytes to the upload-url above, to store the resulting key.
      operationId: confirmOrganizationPhoto
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
        - $ref: '#/components/parameters/PhotoSlot'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - photo_key
              properties:
                photo_key:
                  type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - photo_key
                properties:
                  photo_key:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
        - Organizations
        - Photos
      summary: Remove a photo (Admin or org editor)
      operationId: deleteOrganizationPhoto
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
        - $ref: '#/components/parameters/PhotoSlot'
      responses:
        '204':
          description: No Content
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /deep-links/resolve:
    get:
      tags:
        - Notifications
      summary: Which of the caller's schools does a tapped link belong to
      description: 'A tapped Universal Link or push only carries a path such as `/notices/{id}` — nothing in it names a school. The app calls this before navigating: if the answer is a different school from the one selected, it switches first; on `404` it opens quietly and goes nowhere (no "not found" screen). A school is returned only when opening the item there would succeed for this caller — same membership, feature-toggle, audience and expiry rules as the item''s own detail route. Recognized paths: `/notices/{uuid}`, `/polls/{uuid}`, `/permission-slips/{uuid}`, `/documents/{uuid}`, `/signups/{uuid}`, `/organizations/{shortName}`. An organization''s short name is unique only within a school: `active_school_id` wins when that school has one by that name, otherwise the caller''s only school that does (two candidates and no preference is a `404`).'
      operationId: resolveDeepLink
      parameters:
        - name: path
          in: query
          required: true
          schema:
            type: string
          description: The link's path, e.g. `/notices/5b1b2b1e-6b0e-4b0a-9b0a-5b1b2b1e6b0e`.
        - name: active_school_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
          description: The school currently selected in the app.
      responses:
        '200':
          description: The school to open the item in
          content:
            application/json:
              schema:
                type: object
                required:
                  - school_id
                properties:
                  school_id:
                    type: string
                    format: uuid
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /notifications/{notificationId}/read:
    post:
      tags:
        - Notifications
      summary: Mark one of the caller's own notifications read
      operationId: markNotificationRead
      parameters:
        - $ref: '#/components/parameters/NotificationId'
      responses:
        '204':
          description: Marked read
        '401':
          $ref: '#/components/responses/Unauthorized'
      description: Marks one of the caller's own notifications read. One-directional (it is never marked unread again) and idempotent.
  /schools/{schoolId}/permission-slips:
    post:
      tags:
        - Permission Slips
      summary: Create a permission slip
      description: 'An admin, or an Organization Editor issuing for an organization they hold a live grant on (`organization_id`). Create only: permission slips cannot be edited or deleted through this API. 404 when the `permission_slips` feature is off.'
      operationId: createPermissionSlip
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - title
                - required_by
              properties:
                title:
                  type: string
                body_delta:
                  type: object
                  description: Rich-text Delta document for the body.
                audience_id:
                  type: string
                  format: uuid
                  nullable: true
                organization_id:
                  type: string
                  format: uuid
                  description: Issue for this organization. An Organization Editor's only path.
                required_by:
                  type: string
                  format: date
                  description: The date the slip must be completed by.
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                type: object
                required:
                  - id
                properties:
                  id:
                    type: string
                    format: uuid
        '400':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Validation failed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not found
    get:
      tags:
        - Permission Slips
      summary: List permission slips visible to the caller
      description: 'Every permission slip that has at least one of the caller''s own linked students eligible for it. A slip never auto-hides once its `required_by` date passes: completed and outstanding slips both stay in the list, sorted by `required_by` ascending (most urgent first). 404s if the school has the `permission_slips` feature toggle turned off.'
      operationId: getPermissionSlips
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - permission_slips
                properties:
                  permission_slips:
                    type: array
                    items:
                      $ref: '#/components/schemas/PermissionSlip'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The `permission_slips` feature is toggled off for this school.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /schools/{schoolId}/permission-slips/{slipId}:
    patch:
      tags:
        - Permission Slips
      summary: Update a permission slip (admin, or an Editor of the organization it belongs to)
      description: 'A full re-submit of the slip (`title`, `body_delta`, and `required_by` are required). Authorization comes from the access token, never the body: an admin of the school may also set `audience_id` (or point the slip at an organization with `organization_id`); an Editor of an organization may edit only slips addressed to that organization''s own audience, may not pass `audience_id`, and may name only the organization the slip already belongs to. `403` otherwise.'
      operationId: updatePermissionSlip
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - $ref: '#/components/parameters/SlipId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                body_delta:
                  type: object
                  description: Quill Delta document for the body.
                audience_id:
                  type: string
                  format: uuid
                  description: Admin only.
                  nullable: true
                organization_id:
                  type: string
                  format: uuid
                required_by:
                  type: string
                  format: date
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                required:
                  - id
                properties:
                  id:
                    type: string
                    format: uuid
        '400':
          description: Validation failed (blank title, missing required-by date, body too long, invalid audience).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
        - Permission Slips
      summary: Delete a permission slip (admin, or an Editor of the organization it belongs to)
      description: 'Hard delete (its recorded responses go with it). Same authorization as PATCH: an admin, or an Editor of the organization whose own audience the slip is addressed to; 403 for anyone else (the route re-reads the row to detect a silent refusal), 404 if it does not exist.'
      operationId: deletePermissionSlip
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - $ref: '#/components/parameters/SlipId'
      responses:
        '204':
          description: Deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /permission-slips/{slipId}:
    get:
      tags:
        - Permission Slips
      summary: Get one permission slip
      description: Not found and not-eligible both collapse to a 404 — a caller can't distinguish "doesn't exist" from "not permitted" (mirrors `GET /students/{studentId}`).
      operationId: getPermissionSlip
      parameters:
        - $ref: '#/components/parameters/SlipId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PermissionSlip'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /permission-slips/{slipId}/pdf:
    get:
      tags:
        - Permission Slips
      summary: Get a printable PDF export of the slip
      description: 'A printable PDF of the slip. Returns the PDF bytes (`Content-Type: application/pdf`) rather than a redirect or signed URL. Available to the same callers as `GET /permission-slips/{slipId}`.'
      operationId: getPermissionSlipPdf
      parameters:
        - $ref: '#/components/parameters/SlipId'
        - name: format
          in: query
          required: false
          schema:
            type: string
            enum:
              - letter
              - a4
            default: letter
      responses:
        '200':
          description: The generated PDF.
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /permission-slips/{slipId}/respond:
    post:
      tags:
        - Permission Slips
      summary: Answer the slip (Yes / No) for one or more linked students
      description: 'Records the caller''s answer per student: `granted` (Yes) or `denied` (No, with an optional `reason`). Two request shapes are accepted. Batch: `{ "answers": [{ "student_id", "decision", "reason"? }, ...] }` sends every chosen student at once and is all-or-nothing: if any student is not the caller''s own currently-targeted child, nothing is saved (`403`). Legacy single student: `{ "student_id" }`, optionally with `decision` and `reason`; a missing `decision` is a grant and never overwrites an answer already on file. An answer can be changed (granted or denied, and the reason edited) any number of times until the slip''s window closes, which is the day after `required_by` ends (UTC); after that the route returns `400` and the slip is read-only. Re-sending an unchanged answer is a harmless no-op. Moving back to `granted` clears the reason. `reason` is trimmed, kept only for a denial, and capped at 500 characters. The legacy response field `completed_at` is the first answer''s timestamp.'
      operationId: respondToPermissionSlip
      parameters:
        - $ref: '#/components/parameters/SlipId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                answers:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items:
                    type: object
                    required:
                      - student_id
                    properties:
                      student_id:
                        type: string
                        format: uuid
                      decision:
                        type: string
                        enum:
                          - granted
                          - denied
                        description: Absent = `granted` (older clients).
                      reason:
                        type: string
                        nullable: true
                        maxLength: 500
                        description: Optional, denial only; ignored for `granted`.
                student_id:
                  type: string
                  format: uuid
                  description: Legacy single-student shape (use `answers` instead).
                decision:
                  type: string
                  enum:
                    - granted
                    - denied
                reason:
                  type: string
                  nullable: true
                  maxLength: 500
      responses:
        '200':
          description: Recorded (or unchanged, returned idempotently).
          content:
            application/json:
              schema:
                type: object
                required:
                  - completed_at
                  - answers
                properties:
                  completed_at:
                    type: string
                    format: date-time
                  answers:
                    type: array
                    items:
                      type: object
                      required:
                        - student_id
                        - decision
                        - reason
                        - completed_at
                      properties:
                        student_id:
                          type: string
                          format: uuid
                        decision:
                          type: string
                          enum:
                            - granted
                            - denied
                        reason:
                          type: string
                          nullable: true
                        completed_at:
                          type: string
                          format: date-time
        '400':
          description: Invalid answers (missing `student_id`, unknown `decision`, over-long `reason`, duplicate student, empty list), or the slip's window has closed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: At least one student is not the caller's own guardian-linked child currently targeted by this slip (nothing is saved).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
  /notices/{noticeId}/read:
    post:
      tags:
        - Notices
      summary: Mark a notice read
      description: Marks a notice read for the caller. Private per user and idempotent (already-read is a successful no-op). The notice must be one the caller can see.
      operationId: markNoticeRead
      parameters:
        - name: noticeId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Marked read.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Response
    delete:
      tags:
        - Notices
      summary: Mark a notice unread
      description: Idempotent. Removes the caller's own read marker.
      operationId: markNoticeUnread
      parameters:
        - name: noticeId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Marked unread (or it already was).
        '401':
          $ref: '#/components/responses/Unauthorized'
  /organizations/{organizationId}/calendar-events:
    post:
      tags:
        - Calendar
        - Organizations
      summary: Create an event on an organization's own calendar
      description: Requires an admin or a live Organization Editor grant on that organization, and the school's `calendar` feature to be on. Runs the same create logic as the web's organization-scoped event form (validation, editor authorization, audit log).
      operationId: createOrganizationCalendarEvent
      parameters:
        - $ref: '#/components/parameters/OrganizationId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - title
                - start_date
              properties:
                title:
                  type: string
                description:
                  type: string
                category:
                  type: string
                  description: Defaults to `general`.
                tags:
                  type: string
                  description: Comma-separated.
                start_date:
                  type: string
                  format: date
                end_date:
                  type: string
                  format: date
                start_time:
                  type: string
                  description: HH:MM, 24-hour, school time zone.
                end_time:
                  type: string
                  description: HH:MM, 24-hour, school time zone.
                location:
                  type: string
                  nullable: true
                  description: Optional venue; empty or null for none.
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                type: object
                required:
                  - id
                properties:
                  id:
                    type: string
                    format: uuid
        '400':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Validation failed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not found
  /parents/{parentId}/photo:
    post:
      tags:
        - Photos
      summary: Confirm a completed parent photo upload
      description: Stores `photo_key` on the parent's invitation row (an admin writes that one row; a parent uploading their own photo propagates it to every row with their email at the school, since a photo belongs to the person). `photo_key` must be the key issued for THIS parent. The superseded object is deleted once no other row references it. Allowed for an admin of the row's school, or the parent themselves (matched by sign-in email).
      operationId: confirmParentPhoto
      parameters:
        - name: parentId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - photo_key
              properties:
                photo_key:
                  type: string
      responses:
        '200':
          description: Stored.
          content:
            application/json:
              schema:
                type: object
                required:
                  - photo_key
                properties:
                  photo_key:
                    type: string
        '400':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Validation failed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '500':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Response
    delete:
      tags:
        - Photos
      summary: Remove a parent's photo
      description: Deletes the object from storage and clears `photo_key` (same admin-versus-self propagation as the confirm route). Idempotent, so 204 even when there is no photo.
      operationId: deleteParentPhoto
      parameters:
        - name: parentId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Removed.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
  /parents/{parentId}/photo/upload-url:
    post:
      tags:
        - Photos
      summary: Get a pre-signed upload URL for a parent's photo
      description: Same flow as the student photo upload-url. Allowed for an admin of the row's school or the parent themselves. Maximum size is enforced server side (413 above it).
      operationId: getParentPhotoUploadUrl
      parameters:
        - name: parentId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - content_type
                - content_length
              properties:
                content_type:
                  type: string
                  enum:
                    - image/jpeg
                    - image/png
                    - image/webp
                    - image/gif
                content_length:
                  type: integer
                  minimum: 1
                  description: Size of the file in bytes.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PhotoUploadUrl'
        '400':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Validation failed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '413':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Too large
        '500':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Response
  /rich-text-images/resolve:
    get:
      tags:
        - Notices
      summary: Resolve an inline rich-text image reference to a signed URL
      description: Turns the stable `flux-image://<key>` reference embedded in rich-text content into a 5-minute signed download URL. Keys outside the upload shape (for example path traversal into another folder) are refused.
      operationId: resolveRichTextImage
      parameters:
        - name: key
          in: query
          required: true
          schema:
            type: string
          description: The image key from the `flux-image://` reference.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - url
                  - expires_in
                properties:
                  url:
                    type: string
                    format: uri
                  expires_in:
                    type: integer
                    description: Seconds until the URL expires (300).
        '400':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Validation failed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not found
  /rich-text-images/upload-url:
    post:
      tags:
        - Notices
      summary: Get a pre-signed upload URL for an inline rich-text image
      description: For the native editor's image button. PUT the raw bytes to `upload_url`, then embed `flux-image://<key>` in the content. Allowed for a user who is an admin at any of their schools, or who holds at least one live Organization Editor grant. The gate is deliberately coarse, since the real authorization is at read time.
      operationId: getRichTextImageUploadUrl
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - content_type
                - content_length
              properties:
                content_type:
                  type: string
                  enum:
                    - image/jpeg
                    - image/png
                content_length:
                  type: integer
                  minimum: 1
                  description: Size in bytes.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - upload_url
                  - key
                  - expires_in
                properties:
                  upload_url:
                    type: string
                    format: uri
                  key:
                    type: string
                  expires_in:
                    type: integer
        '400':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Validation failed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '413':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Too large
        '500':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Response
  /schools/{schoolId}/calendar-events/{eventId}:
    patch:
      tags:
        - Calendar
      summary: Edit a calendar event
      description: 'A full-form re-submit (not a sparse patch), except `location`: omitted leaves it unchanged. Authorization is decided by the event itself: a school-wide event needs an admin, an organization event needs an admin or a live Organization Editor grant. Returns the updated event.'
      operationId: updateCalendarEvent
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - name: eventId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CalendarEventInput'
      responses:
        '200':
          description: The updated event (with its series summary fields when it belongs to a series).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CalendarEvent'
        '400':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Validation failed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not found
    delete:
      tags:
        - Calendar
      summary: Delete a calendar event
      description: A hard delete. Same per-event authorization as the edit route; if the caller is refused the event survives and the response is 403.
      operationId: deleteCalendarEvent
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - name: eventId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not found
  /schools/{schoolId}/documents/{documentId}:
    get:
      tags:
        - Document Library
      summary: Get one document
      description: A single document's full detail, by id. The `school-demographics` sentinel id returns the auto-populated School Details entry. A document the caller may not see is a 404, the same as one that does not exist.
      operationId: getDocument
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - $ref: '#/components/parameters/DocumentId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Document'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not found
    patch:
      tags:
        - Document Library
      summary: Edit a document
      description: A full re-submit of the document. Authorization is decided by the document (an admin, or an Organization Editor with a live grant on the organization the document belongs to). Returns the updated document, or `null` if it could not be re-read.
      operationId: updateDocument
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - $ref: '#/components/parameters/DocumentId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DocumentInput'
      responses:
        '200':
          description: The updated document.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Document'
        '400':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Validation failed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not found
    delete:
      tags:
        - Document Library
      summary: Delete a document
      description: A hard delete, including cleanup of the document's stored attachments. If the caller is refused the document survives and the response is 403.
      operationId: deleteDocument
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - $ref: '#/components/parameters/DocumentId'
      responses:
        '204':
          description: Deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not found
  /schools/{schoolId}/guardians:
    get:
      tags:
        - Directory
      summary: List every guardian at the school
      description: 'The whole-school guardian directory, for a client-side cache behind "add to list" pickers. Any school member may call it. Each row is one person: registered (`is_registered: true`, and the id is the membership id) or pending (the id is the invitation id). Fields the caller may not see (for example a parent who chose to hide their contact details) come back `null`.'
      operationId: getSchoolGuardians
      parameters:
        - $ref: '#/components/parameters/SchoolId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - guardians
                properties:
                  guardians:
                    type: array
                    items:
                      type: object
                      required:
                        - id
                        - is_registered
                        - linked_student_names
                      properties:
                        id:
                          type: string
                          format: uuid
                          nullable: true
                        is_registered:
                          type: boolean
                        first_name:
                          type: string
                          nullable: true
                        last_name:
                          type: string
                          nullable: true
                        email:
                          type: string
                          nullable: true
                        phone:
                          type: string
                          nullable: true
                        linked_student_names:
                          type: array
                          items:
                            type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
  /schools/{schoolId}/notices/{noticeId}:
    get:
      tags:
        - Notices
      summary: Get one notice
      description: One notice's full detail, by id, with `is_read` and `audience_label`. A notice the caller may not see answers `404`, the same as one that does not exist.
      operationId: getNotice
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - name: noticeId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Notice'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not found
    patch:
      tags:
        - Notices
      summary: Edit a notice
      description: Fields omitted keep their current value. An admin may change audience, category and flags; an Organization Editor edits through `organization_id` and cannot change the audience. Existing attachments are kept. Returns the updated notice, or `null` if it could not be re-read.
      operationId: updateNotice
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - name: noticeId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NoticeInput'
      responses:
        '200':
          description: The updated notice.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Notice'
        '400':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Validation failed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not found
    delete:
      tags:
        - Notices
      summary: Delete a notice
      description: A hard delete; the notice's read markers go with it. The caller needs the same rights as for an edit.
      operationId: deleteNotice
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - name: noticeId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not found
  /schools/{schoolId}/notices/{noticeId}/attachments/{attachmentId}/download-url:
    get:
      tags:
        - Notices
      summary: Get a short-lived signed URL for one notice attachment
      description: Same shape and 5-minute lifetime as the document attachment route. The notice must be visible to the caller; an unknown attachment is a 404.
      operationId: getNoticeAttachmentDownloadUrl
      parameters:
        - $ref: '#/components/parameters/SchoolId'
        - name: noticeId
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/AttachmentId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PhotoDownloadUrl'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not found
  /staff/{staffId}/photo/download-url:
    get:
      tags:
        - Photos
      summary: Get a short-lived signed URL to view a staff member's photo
      description: Open to any current member of the staff member's school. 5-minute lifetime; request it each time rather than storing it.
      operationId: getStaffPhotoDownloadUrl
      parameters:
        - name: staffId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PhotoDownloadUrl'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not allowed
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Not found
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: 'The access token of a signed-in Flux Student Directory user, sent as `Authorization: Bearer <token>`. Every request is evaluated as that user: the same visibility and permission rules that apply in the product apply here, and the token is never shared across users. Credentials for third-party applications are issued by Flux (flux-support@fluxstudentdirectory.com). Treat tokens as secrets: keep them server-side where you can, never log them, and discard them when the user signs out.'
  parameters:
    SchoolId:
      name: schoolId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    StudentId:
      name: studentId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    ListId:
      name: listId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    OrganizationId:
      name: organizationId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    LeaderId:
      name: leaderId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    MembershipId:
      name: membershipId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    GuardianId:
      name: guardianId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    PhotoSlot:
      name: slot
      in: path
      required: true
      schema:
        type: string
        enum:
          - '1'
          - '2'
          - '3'
    DocumentId:
      name: documentId
      in: path
      required: true
      schema:
        type: string
    AttachmentId:
      name: attachmentId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    NotificationId:
      name: notificationId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    SlipId:
      name: slipId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    PollId:
      name: pollId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    SignupId:
      name: signupId
      in: path
      required: true
      schema:
        type: string
        format: uuid
  responses:
    BadRequest:
      description: 'Validation failed: a field is missing or invalid.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
      description: 'The access token is missing, expired or invalid (`not_authenticated`). Also returned with the code `mfa_required` when the user has enrolled a second factor but the token has not completed it: show a two-factor code prompt, verify, and retry with the upgraded token.'
    Forbidden:
      description: Authenticated, but not authorized for this school/resource/role
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Not found, OR exists but the caller has no access to it — this API deliberately does not distinguish the two (returning 403 instead of 404 for an existing-but-forbidden resource would confirm the resource's existence to a caller who shouldn't know that).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  schemas:
    CalendarSeriesRule:
      type: object
      required:
        - frequency
        - starts_on
      properties:
        frequency:
          type: string
          enum:
            - daily
            - weekly
            - monthly
        interval:
          type: integer
          minimum: 1
          maximum: 52
          default: 1
        weekdays:
          type: array
          nullable: true
          items:
            type: integer
            minimum: 0
            maximum: 6
          description: weekly only; defaults to the start date's weekday
        monthly_mode:
          type: string
          nullable: true
          enum:
            - day_of_month
            - nth_weekday
        day_of_month:
          type: integer
          nullable: true
          minimum: 1
          maximum: 31
        nth:
          type: integer
          nullable: true
          description: 1 to 4, or -1 for the last
        weekday:
          type: integer
          nullable: true
          minimum: 0
          maximum: 6
        starts_on:
          type: string
          format: date
        ends_on:
          type: string
          format: date
          nullable: true
        count:
          type: integer
          nullable: true
          minimum: 1
          maximum: 400
    CalendarSeriesTemplate:
      type: object
      properties:
        title:
          type: string
        description:
          type: string
        category:
          type: string
          enum:
            - general
            - performance
            - closure
            - athletic
            - other
        tags:
          type: string
          description: Comma-separated
        start_date:
          type: string
          format: date
        end_date:
          type: string
          format: date
        start_time:
          type: string
          description: HH:MM or empty for all day
        end_time:
          type: string
        location:
          type: string
    CalendarSeriesCreateBody:
      type: object
      required:
        - template
        - rule
      properties:
        template:
          $ref: '#/components/schemas/CalendarSeriesTemplate'
        rule:
          $ref: '#/components/schemas/CalendarSeriesRule'
        skip_closures:
          type: boolean
        event_id:
          type: string
          format: uuid
          nullable: true
    ClassSchedulePatternBody:
      type: object
      required:
        - weekdays
        - starts_on
        - ends_on
      properties:
        title:
          type: string
          description: Defaults to the class name
        weekdays:
          type: array
          items:
            type: integer
            minimum: 0
            maximum: 6
          description: Sunday is 0
        interval:
          type: integer
          minimum: 1
          maximum: 52
          description: Every N weeks (default 1)
        start_time:
          type: string
          nullable: true
          example: '09:00'
        end_time:
          type: string
          nullable: true
          example: '09:50'
        location:
          type: string
          nullable: true
          description: Defaults to the class location
        starts_on:
          type: string
          format: date
        ends_on:
          type: string
          format: date
        skip_closures:
          type: boolean
          default: true
    ClassSchedulePattern:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: The series id; use it with PATCH/DELETE
        summary:
          type: string
          example: Weekly on Mon, Wed until Dec 18
        weekdays:
          type: array
          items:
            type: integer
        interval:
          type: integer
        start_time:
          type: string
        end_time:
          type: string
        location:
          type: string
        title:
          type: string
        starts_on:
          type: string
          format: date
        ends_on:
          type: string
          format: date
        skip_closures:
          type: boolean
        instance_count:
          type: integer
        edited_count:
          type: integer
    EndOfTermPlan:
      type: object
      required:
        - outcome
        - roster
        - schedule_events
        - notices
        - polls_signups
        - documents
        - staff
      properties:
        outcome:
          type: string
          enum:
            - archive
            - reset_and_continue
        roster:
          type: string
          enum:
            - keep
            - clear
        schedule_events:
          type: string
          enum:
            - keep
            - delete
        notices:
          type: string
          enum:
            - keep
            - archive
        polls_signups:
          type: string
          enum:
            - keep
            - archive
        documents:
          type: string
          enum:
            - keep
            - archive
            - delete
          description: Default keep
        staff:
          type: string
          enum:
            - keep
            - clear
          description: Editor grants are never cleared
    EndOfTermCounts:
      type: object
      properties:
        roster_cleared:
          type: integer
        schedule_events_deleted:
          type: integer
        schedule_series:
          type: integer
        notices_archived:
          type: integer
        polls_archived:
          type: integer
        signups_archived:
          type: integer
        documents_changed:
          type: integer
        staff_cleared:
          type: integer
    EndOfTermResult:
      type: object
      properties:
        already_ran:
          type: boolean
        dry_run:
          type: boolean
        outcome:
          type: string
          enum:
            - archive
            - reset_and_continue
        counts:
          $ref: '#/components/schemas/EndOfTermCounts'
        next_starts_on:
          type: string
          format: date
          nullable: true
        next_ends_on:
          type: string
          format: date
          nullable: true
        lifecycle_after:
          type: string
          enum:
            - active
            - awaiting_dates
            - archived
        awaiting_dates:
          type: boolean
    EndOfTermRun:
      type: object
      properties:
        id:
          type: string
          format: uuid
        ran_at:
          type: string
          format: date-time
        trigger:
          type: string
          enum:
            - scheduled
            - manual
        outcome:
          type: string
          enum:
            - archive
            - reset_and_continue
        term_starts_on:
          type: string
          format: date
          nullable: true
        term_ends_on:
          type: string
          format: date
          nullable: true
        next_starts_on:
          type: string
          format: date
          nullable: true
        next_ends_on:
          type: string
          format: date
          nullable: true
        lifecycle_after:
          type: string
        counts:
          $ref: '#/components/schemas/EndOfTermCounts'
    CalendarSeriesPreview:
      type: object
      required:
        - dates
        - count
        - skipped_for_closures
        - would_exceed_cap
        - cap
      properties:
        dates:
          type: array
          items:
            type: string
            format: date
        count:
          type: integer
        skipped_for_closures:
          type: array
          items:
            type: string
            format: date
        would_exceed_cap:
          type: boolean
        cap:
          type: integer
          example: 400
    CalendarSeries:
      type: object
      required:
        - id
        - rule
        - template
        - skip_closures
        - source
        - summary
        - instance_count
        - edited_count
        - flagged
      properties:
        id:
          type: string
          format: uuid
        organization_id:
          type: string
          format: uuid
          nullable: true
        rule:
          $ref: '#/components/schemas/CalendarSeriesRule'
        template:
          $ref: '#/components/schemas/CalendarSeriesTemplate'
        skip_closures:
          type: boolean
        source:
          type: string
          enum:
            - manual
            - class_schedule
        summary:
          type: string
          example: Weekly on Mon, Wed, Fri until Dec 18
        instance_count:
          type: integer
        edited_count:
          type: integer
        first_date:
          type: string
          format: date
          nullable: true
          description: Earliest start among the instances (null when none are left)
        last_date:
          type: string
          format: date
          nullable: true
          description: Latest end among the instances
        anchor_event_id:
          type: string
          format: uuid
          nullable: true
          description: The earliest instance; any instance opens the edit-all flow
        flagged:
          type: array
          items:
            type: object
            properties:
              event_id:
                type: string
                format: uuid
              occurrence_date:
                type: string
                format: date
              reason:
                type: string
                enum:
                  - falls_on_closure
                  - off_rule
    NoticeInput:
      type: object
      description: Body for creating or editing a notice. Attachments are not accepted through this route (a create starts with none; an edit keeps the existing ones).
      properties:
        title:
          type: string
        body_delta:
          type: object
          description: Rich-text Delta document (present when saved through the rich-text editor). Omitted on edit leaves it untouched.
        audience_id:
          type: string
          format: uuid
          nullable: true
        organization_id:
          type: string
          format: uuid
          description: Publish for this organization. An Organization Editor's only path; the server sets the audience.
        expires_at:
          type: string
          format: date-time
          description: Required on create. Omitted on edit keeps the current value.
        category_id:
          type: string
          format: uuid
          nullable: true
          description: Admins only. Omitted means uncategorized on create and unchanged on edit; null means uncategorized.
        flags:
          type: array
          items:
            type: string
            enum:
              - urgent
          description: Admins only. Omitted on edit leaves them as they are and an empty array clears them. Values outside the allowed set are rejected.
    SignupItemBody:
      type: object
      properties:
        id:
          type: string
          format: uuid
          nullable: true
          description: Existing item id when editing; null for a new item.
        description:
          type: string
        quantity:
          type: integer
          nullable: true
        starts_at:
          type: string
          format: date-time
          nullable: true
          description: Set for a time-slot item.
        ends_at:
          type: string
          format: date-time
          nullable: true
    SaveSignupBody:
      type: object
      properties:
        title:
          type: string
        description_delta:
          type: object
          description: Rich-text Delta document for the description.
        audience_id:
          type: string
          format: uuid
          nullable: true
        organization_ids:
          type: array
          items:
            type: string
            format: uuid
          description: Accepted on edit only. On create the sign-up is never organization-scoped.
        opens_at:
          type: string
          format: date-time
          nullable: true
        closes_at:
          type: string
          format: date-time
          nullable: true
        visible_until:
          type: string
          format: date-time
          description: Required.
        items:
          type: array
          items:
            $ref: '#/components/schemas/SignupItemBody'
    DocumentInput:
      type: object
      description: Body for creating or editing a Document Library entry. An edit is a full re-submit; the synthetic `school-demographics` entry keeps its existing behaviour server side.
      properties:
        title:
          type: string
        body_delta:
          type: object
          description: Rich-text Delta document, present when saved through the rich-text editor.
        audience_id:
          type: string
          format: uuid
          nullable: true
          description: Null = every school member.
        organization_id:
          type: string
          format: uuid
          description: Publish for this organization, class or department. This is an Organization Editor's only path (the server sets the audience); the caller must hold a live grant.
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/DocumentAttachment'
        category_id:
          type: string
          format: uuid
          nullable: true
          description: Omitted means uncategorized on create and unchanged on edit; null means uncategorized.
    CalendarEventInput:
      type: object
      description: Event fields shared by the calendar-event create routes and the edit route. Every field is optional in the body but `title` and `start_date` are required to pass validation.
      properties:
        title:
          type: string
        description:
          type: string
        category:
          type: string
          description: Defaults to `general`.
        tags:
          type: string
          description: Comma-separated.
        start_date:
          type: string
          format: date
        end_date:
          type: string
          format: date
        start_time:
          type: string
          description: HH:MM, 24-hour, in the school's time zone.
        end_time:
          type: string
          description: HH:MM, 24-hour, in the school's time zone.
        location:
          type: string
          nullable: true
          description: Optional venue. On create, empty or null means none. On edit, omitted leaves it unchanged and empty or null clears it.
    Error:
      type: object
      required:
        - error
      description: Every error response uses this shape.
      properties:
        error:
          type: object
          required:
            - code
            - message
          properties:
            code:
              type: string
              description: 'Machine-readable, stable identifier, for example `not_authenticated`, `not_authorized`, `not_found`, `validation_failed` or `mfa_required`. A `401 mfa_required` means the user has enrolled a second factor that this session has not completed: show a two-factor code prompt, then retry with the upgraded token. Branch on this code, never on `message`.'
            message:
              type: string
              description: Human-readable detail, safe to show in a UI error state.
    MeResponse:
      type: object
      required:
        - user_id
        - email
        - memberships
        - is_fully_deactivated
      properties:
        user_id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        is_fully_deactivated:
          type: boolean
          description: True if every membership is currently in its post-deletion grace-period window.
        address_visible:
          type: boolean
          nullable: true
          description: The caller's own home-address visibility to other parents, aggregated over their linked students (true only when all are visible). `null` when the caller has no linked students — nothing to hide — so native clients hide the home-address toggle.
        memberships:
          type: array
          items:
            $ref: '#/components/schemas/Membership'
    Membership:
      type: object
      required:
        - id
        - school_id
        - school_name
        - role
        - profile_visibility
      properties:
        id:
          type: string
          format: uuid
        school_id:
          type: string
          format: uuid
        school_name:
          type: string
        school_logo_url:
          type: string
          nullable: true
        school_latitude:
          type: number
          format: double
          nullable: true
          description: The school's geocoded latitude (schools.latitude), or `null` if the school hasn't been geocoded yet.
        school_longitude:
          type: number
          format: double
          nullable: true
          description: The school's geocoded longitude (schools.longitude), or `null` if the school hasn't been geocoded yet.
        school_status:
          type: string
          enum:
            - active
            - archived
            - suspended
          description: 'The school''s lifecycle status. Anything other than `active` means the school is unavailable: every other endpoint returns no data for it, so clients show a "School unavailable" screen instead of the directory. Always present; defaults to `active` if the school row is missing.'
        school_theme:
          nullable: true
          description: The school's resolved theme, or `null` for the standard look. Already checked against the same format and accessibility rules the web applies.
          allOf:
            - $ref: '#/components/schemas/Theme'
          type: object
        role:
          type: string
          enum:
            - senior_admin
            - junior_admin
            - parent
        profile_visibility:
          type: string
          enum:
            - full
            - name_only
          description: '`full` = contact visible, `name_only` = contact hidden. Set via `PATCH /me/profile-visibility`.'
        deactivated_at:
          type: string
          nullable: true
          format: date-time
    RegistrationConfirmResult:
      type: object
      required:
        - schools
        - students
      properties:
        schools:
          type: array
          items:
            type: object
            required:
              - school_id
              - school_name
              - role
            properties:
              school_id:
                type: string
                format: uuid
              school_name:
                type: string
              role:
                type: string
                enum:
                  - senior_admin
                  - junior_admin
                  - parent
        students:
          type: array
          items:
            type: object
            required:
              - id
              - first_name
              - last_name
            properties:
              id:
                type: string
                format: uuid
              first_name:
                type: string
              last_name:
                type: string
              relation:
                type: string
                nullable: true
    Guardian:
      type: object
      properties:
        first_name:
          type: string
        last_name:
          type: string
        relation:
          type: string
          nullable: true
    RosterStudent:
      type: object
      required:
        - id
        - first_name
        - last_name
        - is_starred
        - is_linked
      properties:
        id:
          type: string
          format: uuid
        first_name:
          type: string
        last_name:
          type: string
        graduation_year:
          type: integer
          nullable: true
        street:
          type: string
          nullable: true
        city:
          type: string
          nullable: true
        state:
          type: string
          nullable: true
        zip:
          type: string
          nullable: true
        photo_key:
          type: string
          nullable: true
        is_starred:
          type: boolean
        is_linked:
          type: boolean
          description: Whether the calling user is a linked guardian of this student.
        guardians:
          type: array
          items:
            $ref: '#/components/schemas/Guardian'
    RosterStudentFull:
      type: object
      description: Same base fields as `RosterStudent` plus latitude/longitude, the caller's own note, and full guardian contact.
      required:
        - id
        - first_name
        - last_name
        - is_starred
        - is_linked
        - note
        - guardians
      properties:
        id:
          type: string
          format: uuid
        first_name:
          type: string
        last_name:
          type: string
        graduation_year:
          type: integer
          nullable: true
        street:
          type: string
          nullable: true
        city:
          type: string
          nullable: true
        state:
          type: string
          nullable: true
        zip:
          type: string
          nullable: true
        latitude:
          type: number
          nullable: true
          format: double
        longitude:
          type: number
          nullable: true
          format: double
        photo_key:
          type: string
          nullable: true
        is_starred:
          type: boolean
        is_linked:
          type: boolean
          description: Whether the calling user is a linked guardian of this student.
        note:
          allOf:
            - $ref: '#/components/schemas/Note'
          nullable: true
          type: object
        guardians:
          type: array
          items:
            $ref: '#/components/schemas/ParentContact'
    Note:
      type: object
      description: At most one per (student, author) - no `id`, since GET/PUT are addressed by student, not by note identity.
      required:
        - body
        - created_at
        - updated_at
      properties:
        body:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ParentContact:
      type: object
      description: '`first_name`/`last_name`/`relation` are never masked.'
      properties:
        id:
          type: string
          format: uuid
        first_name:
          type: string
          nullable: true
        last_name:
          type: string
          nullable: true
        relation:
          type: string
          nullable: true
        email:
          type: string
          format: email
          nullable: true
        phone:
          type: string
          nullable: true
    StudentDetail:
      type: object
      required:
        - id
        - first_name
        - last_name
        - is_starred
        - is_linked
        - note
        - parents
      properties:
        id:
          type: string
          format: uuid
        first_name:
          type: string
        last_name:
          type: string
        graduation_year:
          type: integer
          nullable: true
        street:
          type: string
          nullable: true
        city:
          type: string
          nullable: true
        state:
          type: string
          nullable: true
        zip:
          type: string
          nullable: true
        latitude:
          type: number
          nullable: true
          format: double
        longitude:
          type: number
          nullable: true
          format: double
        photo_key:
          type: string
          nullable: true
        is_starred:
          type: boolean
        is_linked:
          type: boolean
        note:
          nullable: true
          allOf:
            - $ref: '#/components/schemas/Note'
          type: object
        parents:
          type: array
          items:
            $ref: '#/components/schemas/ParentContact'
    MapMarker:
      type: object
      required:
        - id
        - first_name
        - last_name
        - latitude
        - longitude
      properties:
        id:
          type: string
          format: uuid
        first_name:
          type: string
        last_name:
          type: string
        graduation_year:
          type: integer
          nullable: true
        latitude:
          type: number
          format: double
        longitude:
          type: number
          format: double
    StudentList:
      type: object
      required:
        - id
        - name
        - created_at
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        description:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
    StudentListDetail:
      allOf:
        - $ref: '#/components/schemas/StudentList'
        - type: object
          required:
            - students
          properties:
            students:
              type: array
              items:
                type: object
                required:
                  - id
                  - first_name
                  - last_name
                properties:
                  id:
                    type: string
                    format: uuid
                  first_name:
                    type: string
                  last_name:
                    type: string
                  graduation_year:
                    type: integer
                    nullable: true
    Theme:
      type: object
      required:
        - version
        - seeds
        - tokens
      description: A school's or organization's resolved theme - the server derives it from 1-3 seed colors and has already enforced WCAG AA, so clients only apply it (no color math client-side). `null` where it appears means the standard look. Colors are `#rrggbb`; each token exists for both light and dark mode.
      properties:
        version:
          type: integer
        seeds:
          type: array
          items:
            type: string
        tokens:
          type: object
          required:
            - light
            - dark
          properties:
            light:
              $ref: '#/components/schemas/ThemeTokens'
            dark:
              $ref: '#/components/schemas/ThemeTokens'
    ThemeTokens:
      type: object
      required:
        - accent
        - accentHover
        - onAccent
        - accentText
        - accentMuted
        - background
        - surface
        - border
        - headerBackground
        - headerText
      properties:
        accent:
          type: string
          description: Fill for primary buttons, the header and pills.
        accentHover:
          type: string
        onAccent:
          type: string
          description: Label color on an accent fill (black or white, always at least 4.5:1).
        accentText:
          type: string
          description: The accent as text/links/tint on page surfaces.
        accentMuted:
          type: string
          description: Subtle accent-tinted background.
        background:
          type: string
        surface:
          type: string
        border:
          type: string
        headerBackground:
          type: string
        headerText:
          type: string
        highlight:
          type: string
          description: Present only when the theme has a third seed.
        highlightText:
          type: string
        onHighlight:
          type: string
    MyContact:
      type: object
      properties:
        first_name:
          type: string
          nullable: true
        last_name:
          type: string
          nullable: true
        phone:
          type: string
          nullable: true
    MenuImportItem:
      type: object
      required:
        - name
      properties:
        name:
          type: string
        description:
          type: string
          nullable: true
          description: Free text. Nudged (in the web admin form/CSV template, not enforced) toward a visually descriptive sentence — this is what gets fed to on-device `ImagePlayground` image generation on iOS 18+/Apple Intelligence devices, concatenated with `name`.
        category:
          type: string
          nullable: true
        allergens:
          type: string
          nullable: true
    MenuEntry:
      type: object
      required:
        - id
        - date
        - meal_type
        - items
        - needs_review
      properties:
        id:
          type: string
          format: uuid
        date:
          type: string
          format: date
        meal_type:
          type: string
          enum:
            - breakfast
            - am_snack
            - lunch
            - pm_snack
            - other
        items:
          type: array
          items:
            $ref: '#/components/schemas/MenuImportItem'
        notes:
          type: string
          nullable: true
          description: '"vendor substitution due to weather closure".'
        source:
          type: string
          nullable: true
          description: Free text, not a DB enum — e.g.
        recurrence_group_id:
          type: string
          nullable: true
          format: uuid
        needs_review:
          type: boolean
          description: True when a scraper found conflicting content awaiting an admin's keep/use decision — see pending_* fields.
        pending_source:
          type: string
          nullable: true
        pending_items:
          type: array
          nullable: true
          items:
            $ref: '#/components/schemas/MenuImportItem'
        pending_raw_text:
          type: string
          nullable: true
        image_url:
          type: string
          format: uri
          nullable: true
          description: Only ever populated for lunch rows; null until the first successful generation via `POST.../menus/{menuId}/image`. Deterministic per school+date — never changes across a regeneration.
        image_generated_at:
          type: string
          format: date-time
          nullable: true
          description: Updates on every (re)generation - the change-marker a client compares against its own cached copy, since `image_url` itself doesn't change.
    CalendarEvent:
      type: object
      required:
        - id
        - title
        - description
        - category
        - tags
        - start_date
        - end_date
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
        description:
          type: string
        category:
          type: string
          enum:
            - general
            - performance
            - closure
            - athletic
            - other
        tags:
          type: array
          items:
            type: string
          description: Free-form admin-entered tags (e.g. team/production names) for finer-grained filtering than category alone.
        start_date:
          type: string
          format: date
        end_date:
          type: string
          format: date
        start_time:
          type: string
          nullable: true
          description: HH:MM:SS. Null = no specific time / all day (e.g. a closure spanning start_date..end_date).
        end_time:
          type: string
          nullable: true
        location:
          type: string
          nullable: true
          description: Optional venue shown under the event title. Additive - older clients ignore it. Accepted (as `location`, '' or null clears) on the calendar-event create/edit routes; omitted on an edit leaves it unchanged.
    StaffMember:
      type: object
      required:
        - id
        - school_id
        - name
        - title
        - email
        - phone
        - location
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        school_id:
          type: string
          format: uuid
        name:
          type: string
        title:
          type: string
          nullable: true
        email:
          type: string
          format: email
          nullable: true
        phone:
          type: string
          nullable: true
        location:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    DocumentAttachment:
      type: object
      required:
        - id
        - filename
        - content_type
        - size
        - key
      properties:
        id:
          type: string
          format: uuid
        filename:
          type: string
        content_type:
          type: string
        size:
          type: integer
          description: Bytes.
        key:
          type: string
          description: Backblaze B2 private-bucket object key - not a downloadable URL by itself.
    Document:
      type: object
      required:
        - id
        - school_id
        - title
        - body
        - audience_id
        - attachments
        - created_by
        - created_at
        - updated_at
      description: '`id` and `created_by` are plain strings, not always a UUID: the synthetic "School Demographic Details" entry uses the sentinel id `school-demographics` and `created_by: "system"` rather than a real document''s UUID/author id.'
      properties:
        id:
          type: string
          description: A document UUID, or the `school-demographics` sentinel.
        school_id:
          type: string
          format: uuid
        title:
          type: string
        body:
          type: string
          deprecated: true
          description: Deprecated plain-text rendering of `body_delta`, kept only for app builds 4 and earlier. Read `body_delta`; this field will be removed.
        body_delta:
          type: object
          description: 'Quill Delta document: the rich-text body.'
        audience_id:
          type: string
          format: uuid
          nullable: true
          description: Null = visible to every school member. Non-null audiences are already resolved server-side - every Document in this response is already visible to the caller.
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/DocumentAttachment'
        created_by:
          type: string
          description: A user UUID, or "system" for the auto-populated demographics entry.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        category:
          type: string
          nullable: true
          description: The category's NAME (null = uncategorized). Additive.
        category_id:
          type: string
          format: uuid
          nullable: true
          description: A `Category` id; send `category_id` on create/edit (omitted on edit = unchanged, null = uncategorized).
        category_color:
          type: string
          nullable: true
          description: A palette key; null = derive a stable color from the name.
    Category:
      type: object
      description: A per-school content category shared by Documents and Notices. Any member reads; only a Senior Admin writes.
      required:
        - id
        - school_id
        - name
        - color
        - sort_order
        - created_at
      properties:
        id:
          type: string
          format: uuid
        school_id:
          type: string
          format: uuid
        name:
          type: string
          maxLength: 40
          description: Unique per school, case-insensitive.
        color:
          type: string
          nullable: true
          enum:
            - blue
            - purple
            - orange
            - green
            - pink
            - teal
            - indigo
            - red
            - mint
            - cyan
            - brown
            - yellow
          description: Palette key.
        sort_order:
          type: integer
        created_at:
          type: string
          format: date-time
    Notice:
      type: object
      required:
        - id
        - school_id
        - title
        - body
        - audience_id
        - expires_at
        - created_by
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        school_id:
          type: string
          format: uuid
        title:
          type: string
        body:
          type: string
          deprecated: true
          description: Deprecated plain-text rendering of `body_delta`, kept only for app builds 4 and earlier. Read `body_delta`; this field will be removed.
        body_delta:
          type: object
          description: 'Quill Delta document: the rich-text body.'
        audience_id:
          type: string
          format: uuid
          nullable: true
          description: Null = visible to every school member. Non-null audiences are already resolved server-side - every Notice in this response is already visible to the caller.
        expires_at:
          type: string
          format: date-time
          description: Already-expired notices are excluded from this response entirely, not just flagged.
        created_by:
          type: string
          format: uuid
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        category:
          type: string
          nullable: true
          description: The category's NAME (null = uncategorized). Additive.
        category_id:
          type: string
          format: uuid
          nullable: true
          description: A `Category` id; admins send `category_id` on create/edit (omitted on edit = unchanged).
        category_color:
          type: string
          nullable: true
          description: A palette key; null = derive from the name.
        flags:
          type: array
          items:
            type: string
            enum:
              - urgent
          description: Flexible notice flags, `urgent` first. Admins send `flags` on create/edit (omitted on edit = unchanged, [] clears; unknown flag = 400).
        audience_label:
          nullable: true
          description: Who the notice is for, as the apps show it. `organization` = the notice is addressed to an organization's system audience (render that organization's pill, linking to it); `custom` = a saved audience (show its name); null = school-wide.
          oneOf:
            - type: object
              required:
                - kind
                - organization_id
                - name
                - short_name
                - theme
              properties:
                kind:
                  type: string
                  enum:
                    - organization
                organization_id:
                  type: string
                  format: uuid
                name:
                  type: string
                short_name:
                  type: string
                theme:
                  nullable: true
                  allOf:
                    - $ref: '#/components/schemas/Theme'
                  type: object
            - type: object
              required:
                - kind
                - name
              properties:
                kind:
                  type: string
                  enum:
                    - custom
                name:
                  type: string
          type: object
    PermissionSlip:
      type: object
      description: '`students` is only the caller''s own guardian-linked students eligible for this slip (currently audience-matched, or already answered) - never every student targeted by the slip.'
      required:
        - id
        - school_id
        - title
        - body
        - audience_id
        - required_by
        - created_by
        - created_at
        - updated_at
        - students
      properties:
        id:
          type: string
          format: uuid
        school_id:
          type: string
          format: uuid
        title:
          type: string
        body:
          type: string
          deprecated: true
          description: Deprecated plain-text rendering of `body_delta`, kept only for app builds 4 and earlier. Read `body_delta`; this field will be removed.
        body_delta:
          type: object
          description: 'Quill Delta document: the rich-text body.'
        audience_id:
          type: string
          format: uuid
          nullable: true
          description: Null = targeted at every active student at the school.
        required_by:
          type: string
          format: date
          description: Informational due date - a plain calendar date, not a hide-after instant. Does not affect visibility; an outstanding or completed slip stays in the list past this date.
        created_by:
          type: string
          format: uuid
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        students:
          type: array
          items:
            type: object
            required:
              - student_id
              - first_name
              - last_name
              - completed
              - completed_at
              - decision
              - reason
            properties:
              student_id:
                type: string
                format: uuid
              first_name:
                type: string
              last_name:
                type: string
              completed:
                type: boolean
                description: True once the student has ANY answer, granted or denied. An older client therefore treats a denial as "completed".
              completed_at:
                type: string
                format: date-time
                nullable: true
                description: When the CURRENT answer was given (rewritten when the parent changes it).
              decision:
                type: string
                enum:
                  - granted
                  - denied
                nullable: true
                description: Null until answered.
              reason:
                type: string
                nullable: true
                description: The optional reason given with a denial; null otherwise.
    Poll:
      type: object
      required:
        - id
        - school_id
        - title
        - description
        - audience_id
        - results_visibility
        - results_visible_until
        - opens_at
        - closes_at
        - created_by
        - created_at
        - updated_at
        - closed
        - already_responded
      description: The list-route shape - also the base every field of `PollDetail` below extends.
      properties:
        id:
          type: string
          format: uuid
        school_id:
          type: string
          format: uuid
        title:
          type: string
        description:
          type: string
          deprecated: true
          description: Deprecated plain-text rendering of `description_delta`, kept only for app builds 4 and earlier. Read `description_delta`; this field will be removed.
        description_delta:
          type: object
          description: 'Quill Delta document: the rich-text description.'
        audience_id:
          type: string
          format: uuid
          nullable: true
          description: Null = visible to every school member. Non-null audiences are already resolved server-side - every Poll in this response is already visible to the caller.
        results_visibility:
          type: string
          enum:
            - live
            - after_close
            - never
          description: The admin's own choice of when the audience can see results - "live" while voting is open, "after_close" only once closed (until results_visible_until), or "never".
        results_visible_until:
          type: string
          format: date-time
          nullable: true
          description: Only meaningful when results_visibility is "after_close" - null for the other two modes.
        opens_at:
          type: string
          format: date-time
          description: A poll isn't included in this list at all before it goes live - see the `polls` list route's own description.
        closes_at:
          type: string
          format: date-time
        created_by:
          type: string
          format: uuid
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        closed:
          type: boolean
          description: True once closes_at has passed.
        already_responded:
          type: boolean
          description: True if the caller has already submitted a response to this poll (one immutable response per caller).
    PollChoice:
      type: object
      required:
        - id
        - label
        - position
      description: An admin-authored answer choice for a multiple_choice or multi_select PollQuestion. Never present for fill_in_blank questions.
      properties:
        id:
          type: string
          format: uuid
        label:
          type: string
        position:
          type: integer
    PollQuestion:
      type: object
      required:
        - id
        - question_type
        - prompt
        - position
        - choices
      properties:
        id:
          type: string
          format: uuid
        question_type:
          type: string
          enum:
            - multiple_choice
            - multi_select
            - fill_in_blank
        prompt:
          type: string
        position:
          type: integer
        choices:
          type: array
          description: Populated for multiple_choice/multi_select questions, in position order; always empty for fill_in_blank.
          items:
            $ref: '#/components/schemas/PollChoice'
        min_selections:
          type: integer
          nullable: true
          description: multi_select only - null means 1 (at least one choice required). Always null for other question types.
        max_selections:
          type: integer
          nullable: true
          description: multi_select only - null means no cap beyond however many choices exist. Always null for other question types.
    PollQuestionResults:
      type: object
      required:
        - id
        - question_type
        - prompt
        - choice_tallies
        - text_answers
        - total_responses
      description: One question's aggregated results - never includes respondent identity, only aggregate counts/text.
      properties:
        id:
          type: string
          format: uuid
        question_type:
          type: string
          enum:
            - multiple_choice
            - multi_select
            - fill_in_blank
        prompt:
          type: string
        choice_tallies:
          type: array
          description: multiple_choice/multi_select only - one entry per admin-authored choice, in choice position order, count 0 if nobody picked it. Empty for fill_in_blank.
          items:
            type: object
            required:
              - choice_id
              - label
              - count
              - percentage
            properties:
              choice_id:
                type: string
                format: uuid
              label:
                type: string
              count:
                type: integer
              percentage:
                type: integer
                description: Rounded % of respondents (distinct people who answered this question) who picked this choice - NOT % of total selections. For multi_select, a respondent can pick more than one choice, so percentages across a question's choices may sum past 100.
        text_answers:
          type: array
          description: fill_in_blank only - free-text answers in submission order. Empty for multiple_choice/multi_select.
          items:
            type: string
        total_responses:
          type: integer
    PollDetail:
      allOf:
        - $ref: '#/components/schemas/Poll'
        - type: object
          required:
            - questions
            - results
          properties:
            questions:
              type: array
              items:
                $ref: '#/components/schemas/PollQuestion'
            results:
              type: array
              nullable: true
              description: The poll's current results, or null when the poll's own results_visibility mode doesn't allow them to this caller right now - see the `/polls/{pollId}` route's own description. Never reimplement this gate client-side.
              items:
                $ref: '#/components/schemas/PollQuestionResults'
    PollAnswerInput:
      type: object
      required:
        - question_id
        - choice_id
        - answer_text
      description: Exactly one of choice_id/answer_text/choice_ids is populated, matching the question's own type - choice_id for multiple_choice, answer_text for fill_in_blank, choice_ids (a non-empty array, size within the question's min_selections/max_selections) for multi_select. Every question in the poll must appear exactly once in a respond request's answers array.
      properties:
        question_id:
          type: string
          format: uuid
        choice_id:
          type: string
          format: uuid
          nullable: true
        answer_text:
          type: string
          nullable: true
        choice_ids:
          type: array
          description: multi_select only - the set of chosen choice IDs. Omitted/null for other question types.
          items:
            type: string
            format: uuid
    Signup:
      type: object
      required:
        - id
        - school_id
        - title
        - description
        - opens_at
        - closes_at
        - visible_until
        - created_by
        - created_at
        - updated_at
        - closed
        - has_claims
      description: The list-route shape - also the base every field of `SignupDetail` below extends. The web parent UI itself never surfaces this field either.
      properties:
        id:
          type: string
          format: uuid
        school_id:
          type: string
          format: uuid
        title:
          type: string
        description:
          type: string
          deprecated: true
          description: Deprecated plain-text rendering of `description_delta`, kept only for app builds 4 and earlier. Read `description_delta`; this field will be removed.
        description_delta:
          type: object
          description: 'Quill Delta document: the rich-text description.'
        opens_at:
          type: string
          format: date-time
          description: A sign-up isn't included in this list at all before it goes live - see the `signups` list route's own description.
        closes_at:
          type: string
          format: date-time
          description: No further claims, edits, or withdrawals are accepted once this passes.
        visible_until:
          type: string
          format: date-time
          description: The sign-up is no longer shown to its audience at all once this passes, independent of closes_at.
        created_by:
          type: string
          format: uuid
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        closed:
          type: boolean
          description: True once closes_at has passed.
        has_claims:
          type: boolean
          description: True if the caller currently holds at least one claim anywhere on this sign-up.
    SignupItemClaim:
      type: object
      required:
        - display_name
        - note
      description: One current claimant on a SignupItem. Never includes the claimant's user id.
      properties:
        display_name:
          type: string
        note:
          type: string
          nullable: true
    SignupItem:
      type: object
      required:
        - id
        - description
        - quantity
        - starts_at
        - ends_at
        - remaining
        - claims
        - my_claim
      description: One claimable item (or time slot) on a sign-up.
      properties:
        id:
          type: string
          format: uuid
        description:
          type: string
        quantity:
          type: integer
          nullable: true
          description: The claim cap for this item, or null for uncapped.
        starts_at:
          type: string
          format: date-time
          nullable: true
          description: Set together with ends_at for a time-slot item; both null for a plain (non-timed) item.
        ends_at:
          type: string
          format: date-time
          nullable: true
        remaining:
          type: integer
          nullable: true
          description: Live remaining capacity (quantity minus current claim count), recomputed fresh on every read. Null for an uncapped item, never negative.
        claims:
          type: array
          description: Every current claimant on this item, in claim order.
          items:
            $ref: '#/components/schemas/SignupItemClaim'
        my_claim:
          type: object
          nullable: true
          description: The caller's own claim on this item (their own note only - their own name isn't repeated back to them), or null if they haven't claimed it.
          required:
            - note
          properties:
            note:
              type: string
              nullable: true
    SignupDetail:
      allOf:
        - $ref: '#/components/schemas/Signup'
        - type: object
          required:
            - time_slot_items
            - other_items
          properties:
            time_slot_items:
              type: array
              description: Items with a starts_at/ends_at set, sorted chronologically.
              items:
                $ref: '#/components/schemas/SignupItem'
            other_items:
              type: array
              description: Items with no starts_at/ends_at, in admin-chosen order.
              items:
                $ref: '#/components/schemas/SignupItem'
    FeatureToggles:
      type: object
      description: One boolean per known `FeatureKey` — a key with no explicit `false` entry is always present here as `true` (missing from the school's stored config means enabled, not omitted from this map).
      required:
        - lunch_menus
        - document_library
        - directory_map
        - notices
        - staff_directory
        - calendar
        - polls
        - permission_slips
        - signups
      properties:
        lunch_menus:
          type: boolean
        document_library:
          type: boolean
        directory_map:
          type: boolean
        notices:
          type: boolean
        staff_directory:
          type: boolean
        calendar:
          type: boolean
        polls:
          type: boolean
        permission_slips:
          type: boolean
        signups:
          type: boolean
    PhotoUploadUrl:
      type: object
      required:
        - upload_url
        - photo_key
        - expires_in
      properties:
        upload_url:
          type: string
          format: uri
          description: Pre-signed B2 PUT URL. PUT the raw image bytes here directly — do not route them through this API.
        photo_key:
          type: string
          description: Deterministic key (student-photos/{studentId}.{ext}) — pass back unchanged to the confirm endpoint.
        expires_in:
          type: integer
          description: Seconds until upload_url expires.
    PhotoDownloadUrl:
      type: object
      required:
        - download_url
        - expires_in
      properties:
        download_url:
          type: string
          format: uri
        expires_in:
          type: integer
          description: Seconds until download_url expires. Currently 300 (5 minutes), matching the existing web signed-URL TTL.
    WatchPayload:
      type: object
      required:
        - school_id
        - generated_at
        - students
      properties:
        school_id:
          type: string
          format: uuid
        generated_at:
          type: string
          format: date-time
        students:
          type: array
          items:
            type: object
            required:
              - id
              - first_name
              - last_name
              - is_starred
            properties:
              id:
                type: string
                format: uuid
              first_name:
                type: string
              last_name:
                type: string
              graduation_year:
                type: integer
                nullable: true
              is_starred:
                type: boolean
    OrganizationSummary:
      type: object
      required:
        - id
        - school_id
        - category
        - name
        - short_name
        - description
        - archived_at
      description: The list-route shape - core fields only, no leaders/roster/events/photos.
      properties:
        id:
          type: string
          format: uuid
        school_id:
          type: string
          format: uuid
        category:
          type: string
          enum:
            - athletics
            - performing_arts
            - clubs
            - other
        name:
          type: string
        short_name:
          type: string
          description: Unique per school, not globally.
        description:
          type: string
          deprecated: true
          description: Deprecated plain-text rendering of `description_delta`, kept only for app builds 4 and earlier. Read `description_delta`; this field will be removed.
        description_delta:
          type: object
          description: 'Quill Delta document: the rich-text description.'
        archived_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        theme:
          description: List route only. The organization's own resolved theme, or null to inherit the school's.
          nullable: true
          type: object
        can_edit:
          description: List route only. Whether the caller may edit this organization (admin, or a live editor grant); drives Student Detail's organization pill controls.
          type: boolean
    Organization:
      allOf:
        - $ref: '#/components/schemas/OrganizationSummary'
        - type: object
          required:
            - photo_1_key
            - photo_2_key
            - photo_3_key
            - leaders
            - roster
            - events
            - notices
            - can_edit
            - can_manage
          properties:
            photo_1_key:
              type: string
              nullable: true
              description: B2 key, not a URL - fetch a signed URL via GET.../photos/{slot}/download-url.
            photo_2_key:
              type: string
              nullable: true
            photo_3_key:
              type: string
              nullable: true
            theme:
              nullable: true
              description: This organization's own resolved theme, or `null` to inherit the school's (see `school_theme` on /me).
              allOf:
                - $ref: '#/components/schemas/Theme'
              type: object
            leaders:
              type: array
              items:
                $ref: '#/components/schemas/OrganizationLeader'
            roster:
              type: array
              items:
                $ref: '#/components/schemas/OrganizationRosterStudent'
            events:
              type: array
              description: Empty if the `calendar` feature toggle is off.
              items:
                $ref: '#/components/schemas/CalendarEvent'
            features:
              type: object
              additionalProperties:
                type: boolean
              description: 'Per-organization opt-in flags that APPLY to this organization''s category (today `results`); a missing/false key means off. Set with `PATCH /organizations/{id}/settings` `{ features: { results: true } }` (admin only).'
            notices:
              type: array
              description: The notices that target this organization (its own audience, or a composite audience that includes it) that the caller is in the audience for, newest first. Empty if the `notices` feature toggle is off.
              items:
                type: object
                required:
                  - id
                  - title
                  - expires_at
                  - is_own
                properties:
                  id:
                    type: string
                    format: uuid
                  title:
                    type: string
                  expires_at:
                    type: string
                    format: date-time
                  is_own:
                    type: boolean
            documents:
              type: array
              description: The documents that target this organization (its own audience, or a composite audience that includes it) that the caller is in the audience for, plus - for an editor/admin - the organization's own. Empty if the `document_library` feature toggle is off.
              items:
                type: object
                required:
                  - id
                  - title
                  - is_own
                properties:
                  id:
                    type: string
                    format: uuid
                  title:
                    type: string
                  is_own:
                    type: boolean
            permission_slips:
              type: array
              description: The permission slips that target this organization that the caller (a guardian of an eligible student) can respond to, most urgent first, plus - for an editor/admin - the organization's own. Empty if the `permission_slips` feature toggle is off.
              items:
                type: object
                required:
                  - id
                  - title
                  - required_by
                  - is_own
                  - can_open
                properties:
                  id:
                    type: string
                    format: uuid
                  title:
                    type: string
                  required_by:
                    type: string
                    format: date
                  is_own:
                    type: boolean
                    description: True when addressed to THIS organization's own audience - the only ones an editor may edit/delete (PATCH/DELETE /schools/{schoolId}/permission-slips/{slipId}).
                  can_open:
                    type: boolean
                    description: True when the caller can open the parent-facing slip (GET /permission-slips/{slipId}); false for an editor who is not a guardian of an eligible student.
            can_edit:
              type: boolean
              description: Admin or a live org-editor grant - can edit description/photos/leadership/roster.
            can_manage:
              type: boolean
              description: Admin only - archive/re-enable, category/short_name, editor grants.
    OrganizationLeader:
      type: object
      required:
        - id
        - school_staff_id
        - name
        - title
        - display_order
      properties:
        id:
          type: string
          format: uuid
        school_staff_id:
          type: string
          format: uuid
          nullable: true
        name:
          type: string
        title:
          type: string
          nullable: true
          description: The org-specific role label (e.g. Resolved from display_title, falling back to the linked staff record's own title only when display_title is unset.
        display_order:
          type: integer
    OrganizationResult:
      type: object
      description: A team/organization-level result; never linked to a student.
      required:
        - id
        - organization_id
        - calendar_event_id
        - result_date
        - title
        - competitor
        - outcome
        - score_summary
        - notes_delta
        - sort_order
      properties:
        id:
          type: string
          format: uuid
        organization_id:
          type: string
          format: uuid
        calendar_event_id:
          type: string
          format: uuid
          nullable: true
          description: Optional link to one of this organization's events. Deleting the event keeps the result (it becomes standalone).
        result_date:
          type: string
          format: date
        title:
          type: string
          description: e.g. '200m Freestyle', 'Board 1'.
        competitor:
          type: string
        outcome:
          type: string
          nullable: true
          enum:
            - win
            - loss
            - tie
            - no_contest
        score_summary:
          type: string
          description: 'Free text: ''21-14'', ''1:04.32'', ''3rd of 12''.'
        notes_delta:
          type: object
          description: Quill Delta (rich-text notes).
        sort_order:
          type: integer
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    OrganizationResultInput:
      type: object
      required:
        - result_date
      properties:
        result_date:
          type: string
          format: date
        calendar_event_id:
          type: string
          format: uuid
          nullable: true
        title:
          type: string
          maxLength: 200
        competitor:
          type: string
          maxLength: 200
        outcome:
          type: string
          nullable: true
          enum:
            - win
            - loss
            - tie
            - no_contest
        score_summary:
          type: string
          maxLength: 200
        notes_delta:
          type: object
        sort_order:
          type: integer
          description: Optional; assigned (appended within the event/day) when omitted.
    OrganizationRosterStudent:
      type: object
      required:
        - id
        - first_name
        - last_name
        - graduation_year
        - photo_key
      properties:
        id:
          type: string
          format: uuid
        first_name:
          type: string
        last_name:
          type: string
        graduation_year:
          type: integer
          nullable: true
        photo_key:
          type: string
          nullable: true
    OrganizationParentMember:
      type: object
      required:
        - id
        - membership_id
        - user_id
        - email
        - added_by
        - created_at
      description: A parent membership of an organization, independent of the student roster or leadership. `added_by` is null for a self-service join and the adding user's id for an Editor/Admin-added row.
      properties:
        id:
          type: string
          format: uuid
        membership_id:
          type: string
          format: uuid
        user_id:
          type: string
          format: uuid
          nullable: true
        email:
          type: string
          nullable: true
        added_by:
          type: string
          format: uuid
          nullable: true
        created_at:
          type: string
          format: date-time
    OrganizationCandidate:
      type: object
      required:
        - id
        - email
        - role
      description: A minimal membership row for the add-member/add-editor picker.
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          nullable: true
        role:
          type: string
    MessageRecipient:
      type: object
      required:
        - id
        - name
        - email
        - phone
      description: A resolved "Message Everyone" candidate for an organization or list - deduplicated by email, with phone included. A recipient with neither email nor phone is never returned.
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        email:
          type: string
          nullable: true
        phone:
          type: string
          nullable: true
    ListGuardian:
      type: object
      required:
        - id
        - guardian_membership_id
        - linked_student_id
        - user_id
        - email
        - added_by
        - created_at
      description: An individual parent/guardian added directly to a Custom List, not derived from a student being on the list.
      properties:
        id:
          type: string
          format: uuid
        guardian_membership_id:
          type: string
          format: uuid
        linked_student_id:
          type: string
          format: uuid
          nullable: true
        user_id:
          type: string
          format: uuid
          nullable: true
        email:
          type: string
          nullable: true
        added_by:
          type: string
          format: uuid
        created_at:
          type: string
          format: date-time
