{
  "openapi": "3.1.0",
  "info": {
    "title": "Hangout API",
    "version": "0.1.0",
    "summary": "Private group scheduling without accounts.",
    "description": "Create a private plan, share it, propose days, answer for a person who asked, and read ranked time windows. Access uses opaque capabilities, not accounts: the join link yields X-Join-Capability, the planner key and participant keys go in X-Participant-Capability. Several routes answer HTTP 200 with a status field even when the request failed, so always read status. Invite-only private beta; the contract may still change. Human-readable reference: https://hangout-six-peach.vercel.app/developers"
  },
  "servers": [
    {
      "url": "https://hangout-api-ulxjff27ga-od.a.run.app",
      "description": "Hangout API (Cloud Run)"
    }
  ],
  "tags": [
    {
      "name": "Service"
    },
    {
      "name": "Plans"
    },
    {
      "name": "Calendar",
      "description": "Optional Google Calendar assistance. Connecting needs a person in a browser; manual availability always works."
    }
  ],
  "paths": {
    "/health": {
      "get": {
        "operationId": "getHealth",
        "summary": "Liveness check",
        "description": "Returns the text ok. Not rate limited.",
        "tags": [
          "Service"
        ],
        "security": [],
        "responses": {
          "200": {
            "description": "The service is up.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string",
                  "const": "ok"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        }
      }
    },
    "/api/plans": {
      "post": {
        "operationId": "createPlan",
        "summary": "Create a plan",
        "description": "Creates a private plan and its planner. The response is the only one that carries join_link and creator_edit_capability; keep both, the server stores only hashes and cannot return them again. The Idempotency-Key header is required.",
        "tags": [
          "Plans"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKeyRequired"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreatePlanRequest"
              }
            }
          }
        },
        "security": [],
        "responses": {
          "201": {
            "description": "Plan created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreatePlanResponse"
                }
              }
            }
          },
          "409": {
            "description": "The Idempotency-Key is not a UUID v4, was used with a different body, or the creation was already completed. The field is idempotency_key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "422": {
            "description": "Validation failed (title, planner_name or timezone), or the body is not valid JSON of the expected shape.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        }
      }
    },
    "/api/plans/{plan_id}/workspace": {
      "get": {
        "operationId": "getWorkspace",
        "summary": "Read the plan state for the caller",
        "description": "Always answers HTTP 200; the status field says what happened. With X-Participant-Capability: ready (plan, caller, role, participants, configuration) or unauthorised_device. With only X-Join-Capability: join_required (plan title and timezone) or invalid_link. Failure carries no plan data.",
        "tags": [
          "Plans"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PlanId"
          }
        ],
        "security": [
          {
            "ParticipantCapability": []
          },
          {
            "JoinCapability": []
          },
          {}
        ],
        "responses": {
          "200": {
            "description": "Workspace state, discriminated by status.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/WorkspaceReady"
                    },
                    {
                      "$ref": "#/components/schemas/WorkspaceJoinRequired"
                    },
                    {
                      "$ref": "#/components/schemas/WorkspaceInvalidLink"
                    },
                    {
                      "$ref": "#/components/schemas/UnauthorisedDevice"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        }
      }
    },
    "/api/plans/{plan_id}/join": {
      "post": {
        "operationId": "joinPlan",
        "summary": "Join a plan",
        "description": "Adds a participant. Needs the join capability (the part of join_link after #join=). Returns the participant key once. Not idempotent: Idempotency-Key is ignored, and repeating a request whose response was lost returns 409 on display_name.",
        "tags": [
          "Plans"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PlanId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/JoinPlanRequest"
              }
            }
          }
        },
        "security": [
          {
            "JoinCapability": []
          }
        ],
        "responses": {
          "201": {
            "description": "Joined.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JoinPlanResponse"
                }
              }
            }
          },
          "404": {
            "description": "The join capability is missing or invalid (field join_capability).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "409": {
            "description": "The display name is already taken in this plan (field display_name), or the plan has reached its limit of 50 participants (field plan).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "422": {
            "description": "The display name is empty or longer than 150 characters, or the body is not valid.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        }
      }
    },
    "/api/plans/{plan_id}/availability": {
      "get": {
        "operationId": "getAvailability",
        "summary": "Read the proposal and the caller's own answer",
        "description": "Returns the planner's proposed ranges and only the caller's own answered ranges; other participants' availability is never included.",
        "tags": [
          "Plans"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PlanId"
          }
        ],
        "security": [
          {
            "ParticipantCapability": []
          }
        ],
        "responses": {
          "200": {
            "description": "Proposal and own answer.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GetAvailabilityResponse"
                }
              }
            }
          },
          "404": {
            "description": "The participant capability is missing or invalid (field participant_capability).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        }
      },
      "put": {
        "operationId": "putAvailability",
        "summary": "Replace the caller's whole answer",
        "description": "Replaces the participant's answer. An empty days list withdraws it. Only a participant key may answer: the planner key gets 403, because the planner's availability is the proposal itself. Ranges of one date are merged. Overlap results are recalculated in the background.",
        "tags": [
          "Plans"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PlanId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DaysRequest"
              }
            }
          }
        },
        "security": [
          {
            "ParticipantCapability": []
          }
        ],
        "responses": {
          "200": {
            "description": "Answer stored.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PutAvailabilityResponse"
                }
              }
            }
          },
          "403": {
            "description": "The planner key was used.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Forbidden"
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "The participant capability is missing or invalid (field participant_capability).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "409": {
            "description": "The Idempotency-Key is not a UUID v4, or was used with a different body.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IdempotencyConflict"
                }
              }
            }
          },
          "422": {
            "description": "A range is invalid; field names the item, for example days[0].range.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        }
      }
    },
    "/api/plans/{plan_id}/proposed-days": {
      "put": {
        "operationId": "putProposedDays",
        "summary": "Replace the whole proposal (planner)",
        "description": "Planner only. Replaces the proposed days. Refused with 409 locked_days, writing nothing, when it would remove a date that others answered or stop covering answered minutes. A newly proposed day must fall between today and one year ahead. HTTP 200 is not proof of success: unauthorised_device and forbidden are 200 bodies.",
        "tags": [
          "Plans"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PlanId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DaysRequest"
              }
            }
          }
        },
        "security": [
          {
            "ParticipantCapability": []
          }
        ],
        "responses": {
          "200": {
            "description": "Outcome, discriminated by status: ok, forbidden or unauthorised_device.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ProposedDaysOk"
                    },
                    {
                      "$ref": "#/components/schemas/Forbidden"
                    },
                    {
                      "$ref": "#/components/schemas/UnauthorisedDevice"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                }
              }
            }
          },
          "409": {
            "description": "Refused: locked_days, or idempotency_conflict.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/LockedDays"
                    },
                    {
                      "$ref": "#/components/schemas/IdempotencyConflict"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                }
              }
            }
          },
          "422": {
            "description": "A range is invalid; field names the item, for example days[0].date.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        }
      }
    },
    "/api/plans/{plan_id}/overlap-results": {
      "get": {
        "operationId": "getOverlapResults",
        "summary": "Read ranked time windows",
        "description": "Ranked windows, each naming who is free across the whole window. The planner is never counted or listed. Trust the candidates only when calculated_revision equals current_revision.",
        "tags": [
          "Plans"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PlanId"
          }
        ],
        "security": [
          {
            "ParticipantCapability": []
          }
        ],
        "responses": {
          "200": {
            "description": "Ranked windows.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OverlapResults"
                }
              }
            }
          },
          "404": {
            "description": "The participant capability is missing or invalid.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        }
      }
    },
    "/api/plans/{plan_id}/configuration": {
      "get": {
        "operationId": "getConfiguration",
        "summary": "Read the offered activity categories",
        "description": "Returns the offered categories and the configuration revision.",
        "tags": [
          "Plans"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PlanId"
          }
        ],
        "security": [
          {
            "ParticipantCapability": []
          }
        ],
        "responses": {
          "200": {
            "description": "Configuration.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlanConfiguration"
                }
              }
            }
          },
          "401": {
            "description": "The participant capability is missing or invalid.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        }
      },
      "put": {
        "operationId": "putConfiguration",
        "summary": "Change the offered activity categories (planner)",
        "description": "Planner only. An empty list marks the activity as decided. base_revision must match the current revision.",
        "tags": [
          "Plans"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PlanId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateConfigurationRequest"
              }
            }
          }
        },
        "security": [
          {
            "ParticipantCapability": []
          }
        ],
        "responses": {
          "200": {
            "description": "Outcome: ok, forbidden or unauthorised_device. Without an Idempotency-Key, unauthorised_device may also mean a transient server storage failure, so retrying once is reasonable.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ConfigurationOk"
                    },
                    {
                      "$ref": "#/components/schemas/Forbidden"
                    },
                    {
                      "$ref": "#/components/schemas/UnauthorisedDevice"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                }
              }
            }
          },
          "400": {
            "description": "duplicate_categories, or the body is malformed.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/DuplicateCategories"
                    },
                    {
                      "$ref": "#/components/schemas/ErrorEnvelope"
                    }
                  ]
                }
              }
            }
          },
          "409": {
            "description": "stale_edit or idempotency_conflict.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/StaleEdit"
                    },
                    {
                      "$ref": "#/components/schemas/IdempotencyConflict"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                }
              }
            }
          },
          "422": {
            "description": "The body is not valid, for example an unknown category.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalErrorOrStatus"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        }
      }
    },
    "/api/plans/{plan_id}/activity-preferences": {
      "get": {
        "operationId": "getActivityPreferences",
        "summary": "Read the group's activity preferences",
        "description": "Aggregated tallies and each participant's individual choice.",
        "tags": [
          "Plans"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PlanId"
          }
        ],
        "security": [
          {
            "ParticipantCapability": []
          }
        ],
        "responses": {
          "200": {
            "description": "Preferences.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GroupActivityPreferences"
                }
              }
            }
          },
          "401": {
            "description": "The participant capability is missing or invalid.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        }
      },
      "post": {
        "operationId": "submitActivityPreference",
        "summary": "Save the caller's activity preference",
        "description": "Replaces the caller's whole preference. configuration_revision must be the revision last read. HTTP 200 can carry unauthorised_device. A storage failure answers 500 with a JSON body whose status is unauthorised_device.",
        "tags": [
          "Plans"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PlanId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ActivityPreferenceInput"
              }
            }
          }
        },
        "security": [
          {
            "ParticipantCapability": []
          }
        ],
        "responses": {
          "200": {
            "description": "Outcome: ok or unauthorised_device.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ActivityPreferenceOk"
                    },
                    {
                      "$ref": "#/components/schemas/UnauthorisedDevice"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                }
              }
            }
          },
          "400": {
            "description": "invalid_categories, or the body is malformed.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/InvalidCategories"
                    },
                    {
                      "$ref": "#/components/schemas/ErrorEnvelope"
                    }
                  ]
                }
              }
            }
          },
          "409": {
            "description": "stale_edit or idempotency_conflict.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/StaleEdit"
                    },
                    {
                      "$ref": "#/components/schemas/IdempotencyConflict"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                }
              }
            }
          },
          "422": {
            "description": "custom_suggestion_too_long, or the body is not valid.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/CustomSuggestionTooLong"
                    },
                    {
                      "$ref": "#/components/schemas/ErrorEnvelope"
                    }
                  ]
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalErrorOrStatus"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        }
      }
    },
    "/api/plans/{plan_id}/participants/me": {
      "patch": {
        "operationId": "renameSelf",
        "summary": "Rename the caller",
        "description": "Changes the caller's display name. base_revision is the display_name_revision last read from the workspace. HTTP 200 does not mean success: check status.",
        "tags": [
          "Plans"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PlanId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RenameRequest"
              }
            }
          }
        },
        "security": [
          {
            "ParticipantCapability": []
          }
        ],
        "responses": {
          "200": {
            "description": "Outcome, discriminated by status. Without an Idempotency-Key, unauthorised_device may also mean a transient server storage failure, so retrying once is reasonable.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/RenameOk"
                    },
                    {
                      "$ref": "#/components/schemas/DuplicateName"
                    },
                    {
                      "$ref": "#/components/schemas/InvalidName"
                    },
                    {
                      "$ref": "#/components/schemas/UnauthorisedDevice"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                }
              }
            }
          },
          "409": {
            "description": "stale_edit or idempotency_conflict.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/StaleEdit"
                    },
                    {
                      "$ref": "#/components/schemas/IdempotencyConflict"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalErrorOrStatus"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        }
      }
    },
    "/api/plans/{plan_id}/participants/{participant_id}": {
      "patch": {
        "operationId": "renameParticipant",
        "summary": "Rename a participant (planner)",
        "description": "Planner only. Same body and outcomes as renameSelf, plus forbidden for a non-planner.",
        "tags": [
          "Plans"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PlanId"
          },
          {
            "$ref": "#/components/parameters/ParticipantId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RenameRequest"
              }
            }
          }
        },
        "security": [
          {
            "ParticipantCapability": []
          }
        ],
        "responses": {
          "200": {
            "description": "Outcome, discriminated by status. Without an Idempotency-Key, unauthorised_device may also mean a transient server storage failure, so retrying once is reasonable.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/RenameOk"
                    },
                    {
                      "$ref": "#/components/schemas/DuplicateName"
                    },
                    {
                      "$ref": "#/components/schemas/InvalidName"
                    },
                    {
                      "$ref": "#/components/schemas/UnauthorisedDevice"
                    },
                    {
                      "$ref": "#/components/schemas/Forbidden"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                }
              }
            }
          },
          "409": {
            "description": "stale_edit or idempotency_conflict.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/StaleEdit"
                    },
                    {
                      "$ref": "#/components/schemas/IdempotencyConflict"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalErrorOrStatus"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        }
      }
    },
    "/api/plans/{plan_id}/calendar": {
      "get": {
        "operationId": "getCalendarStatus",
        "summary": "Google Calendar connection status",
        "description": "Whether the caller connected Google Calendar. Always HTTP 200. Connecting itself needs a person in a browser.",
        "tags": [
          "Calendar"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PlanId"
          }
        ],
        "security": [
          {
            "ParticipantCapability": []
          }
        ],
        "responses": {
          "200": {
            "description": "Status.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/CalendarConnected"
                    },
                    {
                      "$ref": "#/components/schemas/CalendarDisconnected"
                    },
                    {
                      "$ref": "#/components/schemas/CalendarUnavailable"
                    },
                    {
                      "$ref": "#/components/schemas/UnauthorisedDevice"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        }
      },
      "delete": {
        "operationId": "disconnectCalendar",
        "summary": "Disconnect Google Calendar",
        "description": "Deletes the caller's stored calendar connection. Always HTTP 200.",
        "tags": [
          "Calendar"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PlanId"
          }
        ],
        "security": [
          {
            "ParticipantCapability": []
          }
        ],
        "responses": {
          "200": {
            "description": "Outcome.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/CalendarDisconnected"
                    },
                    {
                      "$ref": "#/components/schemas/CalendarUnavailable"
                    },
                    {
                      "$ref": "#/components/schemas/UnauthorisedDevice"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        }
      }
    },
    "/api/plans/{plan_id}/calendar/connect": {
      "post": {
        "operationId": "startCalendarConnection",
        "summary": "Start Google Calendar consent (needs a browser)",
        "description": "Returns a Google consent URL (free/busy scope only). A person must open it in a browser and consent, so an agent cannot complete this flow. Always HTTP 200.",
        "tags": [
          "Calendar"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PlanId"
          }
        ],
        "security": [
          {
            "ParticipantCapability": []
          }
        ],
        "responses": {
          "200": {
            "description": "Outcome.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/AuthorizationRequired"
                    },
                    {
                      "$ref": "#/components/schemas/CalendarUnavailable"
                    },
                    {
                      "$ref": "#/components/schemas/UnauthorisedDevice"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        },
        "x-requires-browser": true
      }
    },
    "/api/plans/{plan_id}/calendar/complete": {
      "post": {
        "operationId": "completeCalendarConnection",
        "summary": "Bind the OAuth return ticket (browser flow)",
        "description": "Part of the web app's OAuth round trip: presents the one-time ticket from the redirect fragment together with the initiating participant's key. Every failure answers the same unavailable. Not meant for agents.",
        "tags": [
          "Calendar"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PlanId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CompleteCalendarRequest"
              }
            }
          }
        },
        "security": [
          {
            "ParticipantCapability": []
          },
          {}
        ],
        "responses": {
          "200": {
            "description": "Outcome.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/CalendarConnected"
                    },
                    {
                      "$ref": "#/components/schemas/CalendarUnavailable"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        },
        "x-requires-browser": true
      }
    },
    "/api/plans/{plan_id}/calendar/freebusy": {
      "post": {
        "operationId": "getCalendarFreeBusy",
        "summary": "Read opaque busy blocks (browser flow)",
        "description": "Returns the caller's busy intervals, without event details, for a window of at most 31 days. Requires a calendar connection made in a browser.",
        "tags": [
          "Calendar"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PlanId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FreeBusyRequest"
              }
            }
          }
        },
        "security": [
          {
            "ParticipantCapability": []
          }
        ],
        "responses": {
          "200": {
            "description": "Outcome.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/FreeBusyConnected"
                    },
                    {
                      "$ref": "#/components/schemas/CalendarDisconnected"
                    },
                    {
                      "$ref": "#/components/schemas/CalendarUnavailable"
                    },
                    {
                      "$ref": "#/components/schemas/UnauthorisedDevice"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                }
              }
            }
          },
          "422": {
            "description": "time_range: unparseable or longer than 31 days.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        },
        "x-requires-browser": true
      }
    },
    "/api/plans/{plan_id}/calendar/classifications": {
      "put": {
        "operationId": "classifyCalendarBusyBlocks",
        "summary": "Save busy block classifications (browser flow)",
        "description": "Saves whether each busy block blocks availability. At most 200 blocks.",
        "tags": [
          "Calendar"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PlanId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ClassifyRequest"
              }
            }
          }
        },
        "security": [
          {
            "ParticipantCapability": []
          }
        ],
        "responses": {
          "200": {
            "description": "Outcome.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/CalendarConnected"
                    },
                    {
                      "$ref": "#/components/schemas/CalendarDisconnected"
                    },
                    {
                      "$ref": "#/components/schemas/CalendarUnavailable"
                    },
                    {
                      "$ref": "#/components/schemas/UnauthorisedDevice"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                }
              }
            }
          },
          "422": {
            "description": "More than 200 blocks (field blocks), or an invalid body.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        },
        "x-requires-browser": true
      }
    },
    "/auth/google/callback": {
      "get": {
        "operationId": "googleOAuthCallback",
        "summary": "Google OAuth redirect target (browser only)",
        "description": "Receives Google's redirect with code and state, then redirects the browser to the web app. Never call this from an agent; it exists only for Google's redirect.",
        "tags": [
          "Calendar"
        ],
        "parameters": [
          {
            "name": "code",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Authorization code from Google."
          },
          {
            "name": "state",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "One-time state issued by startCalendarConnection."
          }
        ],
        "security": [],
        "responses": {
          "303": {
            "description": "Redirect to the web app, with a completion ticket in the URL fragment on success or ?calendar=unavailable on failure.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string",
                  "format": "uri"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "default": {
            "$ref": "#/components/responses/GenericError"
          }
        },
        "x-requires-browser": true
      }
    }
  },
  "components": {
    "securitySchemes": {
      "JoinCapability": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Join-Capability",
        "description": "The secret after #join= in join_link. Lets the holder read the plan title and join."
      },
      "ParticipantCapability": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Participant-Capability",
        "description": "The planner key (creator_edit_capability) or a participant key (edit_capability). Valid for one plan only."
      }
    },
    "parameters": {
      "PlanId": {
        "name": "plan_id",
        "in": "path",
        "required": true,
        "schema": {
          "$ref": "#/components/schemas/Uuid"
        },
        "description": "Plan identifier, from the plan creation response."
      },
      "ParticipantId": {
        "name": "participant_id",
        "in": "path",
        "required": true,
        "schema": {
          "$ref": "#/components/schemas/Uuid"
        }
      },
      "IdempotencyKeyRequired": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "description": "A fresh UUID v4 per intended creation. Creation is never replayed: a repeat of a completed creation returns 409."
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "description": "Optional UUID v4. Replaying the same key with the same body returns the recorded response; the same key with a different body returns 409. Every response after the key is accepted is recorded and replayed, including 400, 409 and 422 refusals, so use a fresh key after correcting a request. Not recorded: the invalid-day 422 on PUT proposed-days and PUT availability, storage failures, and refusals returned before the key is accepted (200 unauthorised_device or forbidden, the 404 and 403 on PUT availability, and the 409 idempotency_conflict itself); these may be retried with the same key."
      }
    },
    "responses": {
      "TooManyRequests": {
        "description": "A per-client budget was exhausted. Wait Retry-After seconds.",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait.",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "ServiceUnavailable": {
        "description": "Temporary overload, or the request took longer than 30 seconds. Retry later.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "InternalError": {
        "description": "Unexpected server error.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "InternalErrorOrStatus": {
        "description": "Unexpected server error. Only POST activity-preferences answers a storage failure with 500 and the handler-built {\"status\":\"unauthorised_device\"} body, with or without an Idempotency-Key. On PUT configuration and PATCH participants/me and /{participant_id} a storage failure instead answers HTTP 200 {\"status\":\"unauthorised_device\"} when no Idempotency-Key is sent. With a key, any storage failure (beginning the key, the write itself, or recording the response) answers 500 with the same {\"status\":\"unauthorised_device\"} body, not the errors envelope, and nothing is recorded.",
        "content": {
          "application/json": {
            "schema": {
              "oneOf": [
                {
                  "$ref": "#/components/schemas/UnauthorisedDevice"
                },
                {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              ]
            }
          }
        }
      },
      "GenericError": {
        "description": "Any other error: 400, 404, 405, 413 or 415 for a malformed request, an unknown route, a wrong method, an oversized body or a wrong content type. The body is the errors envelope with field \"request\" and a generic message.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      }
    },
    "schemas": {
      "ErrorEnvelope": {
        "type": "object",
        "required": [
          "errors"
        ],
        "description": "The body of every non-success response that is not a documented status body. Domain errors name a request field; errors produced outside a handler (malformed JSON, unknown route, rate limit, overload, timeout, unexpected failure) use the field \"request\" and a generic message.",
        "properties": {
          "errors": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FieldError"
            }
          }
        }
      },
      "FieldError": {
        "type": "object",
        "required": [
          "field",
          "message"
        ],
        "properties": {
          "field": {
            "type": "string",
            "description": "Request field the error concerns, for example title, display_name, idempotency_key or days[0].range; \"request\" when it concerns the request as a whole."
          },
          "message": {
            "type": "string",
            "description": "English, human-readable explanation."
          }
        }
      },
      "Uuid": {
        "type": "string",
        "format": "uuid"
      },
      "PlanDate": {
        "type": "string",
        "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
        "description": "Calendar day, YYYY-MM-DD, in the plan's timezone."
      },
      "Minutes": {
        "type": "integer",
        "minimum": 0,
        "maximum": 1440,
        "multipleOf": 30,
        "description": "Minutes since midnight in the plan's timezone, a multiple of 30."
      },
      "ActivityCategory": {
        "type": "string",
        "enum": [
          "dinner",
          "drinks",
          "activity",
          "coffee",
          "chill_somewhere",
          "something_else"
        ]
      },
      "CreatePlanRequest": {
        "type": "object",
        "required": [
          "title",
          "planner_name",
          "timezone"
        ],
        "properties": {
          "title": {
            "type": "string",
            "minLength": 1,
            "maxLength": 150,
            "pattern": "\\S"
          },
          "planner_name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 150,
            "pattern": "\\S"
          },
          "timezone": {
            "type": "string",
            "description": "IANA timezone name.",
            "examples": [
              "Europe/Paris"
            ]
          }
        }
      },
      "PlanSummary": {
        "type": "object",
        "required": [
          "id",
          "title",
          "timezone",
          "planner_name"
        ],
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "title": {
            "type": "string"
          },
          "timezone": {
            "type": "string"
          },
          "planner_name": {
            "type": "string"
          }
        }
      },
      "CreatePlanResponse": {
        "type": "object",
        "required": [
          "plan",
          "join_link",
          "creator_edit_capability"
        ],
        "properties": {
          "plan": {
            "$ref": "#/components/schemas/PlanSummary"
          },
          "join_link": {
            "type": "string",
            "description": "Private link to share with the group. The part after #join= is the join capability, sent as X-Join-Capability. Returned only by this response."
          },
          "creator_edit_capability": {
            "type": "string",
            "description": "The planner key, sent as X-Participant-Capability. Returned only by this response; keep it secret."
          }
        }
      },
      "PlanContext": {
        "type": "object",
        "required": [
          "id",
          "title",
          "timezone"
        ],
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "title": {
            "type": "string"
          },
          "timezone": {
            "type": "string"
          }
        }
      },
      "ParticipantSummary": {
        "type": "object",
        "required": [
          "id",
          "display_name",
          "display_name_revision"
        ],
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "display_name": {
            "type": "string"
          },
          "display_name_revision": {
            "type": "integer",
            "format": "int64",
            "description": "Send back as base_revision when renaming."
          }
        }
      },
      "ParticipantRole": {
        "type": "string",
        "enum": [
          "principal_planner",
          "participant"
        ]
      },
      "PlanConfiguration": {
        "type": "object",
        "required": [
          "revision",
          "allowed_categories"
        ],
        "properties": {
          "revision": {
            "type": "integer",
            "format": "int64"
          },
          "allowed_categories": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ActivityCategory"
            },
            "description": "Activity categories offered to participants. Empty means the activity is already decided."
          }
        }
      },
      "WorkspaceReady": {
        "type": "object",
        "required": [
          "status",
          "plan",
          "participant",
          "role",
          "participants",
          "configuration"
        ],
        "properties": {
          "status": {
            "const": "ready"
          },
          "plan": {
            "$ref": "#/components/schemas/PlanContext"
          },
          "participant": {
            "$ref": "#/components/schemas/ParticipantSummary"
          },
          "role": {
            "$ref": "#/components/schemas/ParticipantRole"
          },
          "participants": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ParticipantSummary"
            }
          },
          "configuration": {
            "$ref": "#/components/schemas/PlanConfiguration"
          }
        }
      },
      "WorkspaceJoinRequired": {
        "type": "object",
        "required": [
          "status",
          "plan"
        ],
        "properties": {
          "status": {
            "const": "join_required"
          },
          "plan": {
            "$ref": "#/components/schemas/PlanContext"
          }
        }
      },
      "WorkspaceInvalidLink": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "const": "invalid_link"
          }
        },
        "description": "Unknown plan or wrong join capability, or no capability header at all. Carries no plan data."
      },
      "UnauthorisedDevice": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "const": "unauthorised_device"
          }
        },
        "description": "The X-Participant-Capability header is missing, empty or not valid for this plan. Returned with HTTP 200."
      },
      "Forbidden": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "const": "forbidden"
          }
        },
        "description": "The capability is valid but not allowed to do this (for example a participant key on a planner-only route)."
      },
      "IdempotencyConflict": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "const": "idempotency_conflict"
          }
        },
        "description": "The Idempotency-Key is not a UUID v4, or was already used with a different request body."
      },
      "StaleEdit": {
        "type": "object",
        "required": [
          "status",
          "current_revision"
        ],
        "properties": {
          "status": {
            "const": "stale_edit"
          },
          "current_revision": {
            "type": "integer",
            "format": "int64"
          }
        },
        "description": "The supplied revision is out of date; re-read and retry."
      },
      "JoinPlanRequest": {
        "type": "object",
        "required": [
          "display_name"
        ],
        "properties": {
          "display_name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 150,
            "pattern": "\\S"
          }
        }
      },
      "JoinPlanResponse": {
        "type": "object",
        "required": [
          "participant_id",
          "display_name",
          "edit_capability"
        ],
        "properties": {
          "participant_id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "display_name": {
            "type": "string"
          },
          "edit_capability": {
            "type": "string",
            "description": "The participant key, sent as X-Participant-Capability. Returned only by this response."
          }
        }
      },
      "ProposedDay": {
        "type": "object",
        "required": [
          "date",
          "start_minutes",
          "end_minutes",
          "answered",
          "day_answered"
        ],
        "description": "One proposed range. A date may appear several times.",
        "properties": {
          "date": {
            "$ref": "#/components/schemas/PlanDate"
          },
          "start_minutes": {
            "$ref": "#/components/schemas/Minutes"
          },
          "end_minutes": {
            "$ref": "#/components/schemas/Minutes"
          },
          "answered": {
            "type": "boolean",
            "description": "True once another participant's answer strictly overlaps this range."
          },
          "day_answered": {
            "type": "boolean",
            "description": "True once another participant has an answer anywhere on this date."
          }
        }
      },
      "DayRange": {
        "type": "object",
        "required": [
          "date",
          "start_minutes",
          "end_minutes"
        ],
        "description": "One range of one day. A date may appear several times; overlapping or touching ranges are merged.",
        "properties": {
          "date": {
            "$ref": "#/components/schemas/PlanDate"
          },
          "start_minutes": {
            "$ref": "#/components/schemas/Minutes"
          },
          "end_minutes": {
            "$ref": "#/components/schemas/Minutes"
          }
        }
      },
      "GetAvailabilityResponse": {
        "type": "object",
        "required": [
          "revision",
          "proposed_days",
          "days"
        ],
        "properties": {
          "revision": {
            "type": "integer",
            "format": "int32"
          },
          "proposed_days": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ProposedDay"
            }
          },
          "days": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/DayRange"
            },
            "description": "The caller's own answered ranges. For the planner this mirrors the proposal."
          }
        }
      },
      "DaysRequest": {
        "type": "object",
        "required": [
          "days"
        ],
        "properties": {
          "days": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/DayRange"
            },
            "maxItems": 500,
            "description": "The whole set; it replaces what is stored. An empty list on an answer withdraws it. At most 500 items; more are refused with field days[500].range."
          }
        }
      },
      "PutAvailabilityResponse": {
        "type": "object",
        "required": [
          "revision",
          "recalculation"
        ],
        "properties": {
          "revision": {
            "type": "integer",
            "format": "int32"
          },
          "recalculation": {
            "type": "string",
            "enum": [
              "pending"
            ],
            "description": "Overlap results are recalculated in the background."
          }
        }
      },
      "PutAvailabilityForbidden": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Forbidden"
          }
        ],
        "description": "The planner key was used; the planner's availability is the proposal itself."
      },
      "ProposedDaysOk": {
        "type": "object",
        "required": [
          "status",
          "revision",
          "days"
        ],
        "properties": {
          "status": {
            "const": "ok"
          },
          "revision": {
            "type": "integer",
            "format": "int32"
          },
          "days": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ProposedDay"
            }
          }
        }
      },
      "LockedDays": {
        "type": "object",
        "required": [
          "status",
          "dates"
        ],
        "properties": {
          "status": {
            "const": "locked_days"
          },
          "dates": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlanDate"
            }
          }
        },
        "description": "The proposal would remove or shrink answered time; nothing was written."
      },
      "OverlapCandidate": {
        "type": "object",
        "required": [
          "date",
          "start_minutes",
          "end_minutes",
          "free_count",
          "free_participant_ids"
        ],
        "properties": {
          "date": {
            "$ref": "#/components/schemas/PlanDate"
          },
          "start_minutes": {
            "$ref": "#/components/schemas/Minutes"
          },
          "end_minutes": {
            "$ref": "#/components/schemas/Minutes"
          },
          "free_count": {
            "type": "integer",
            "minimum": 0
          },
          "free_participant_ids": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Sorted ids of everyone free for the whole window; resolve names through participants."
          }
        }
      },
      "OverlapParticipant": {
        "type": "object",
        "required": [
          "id",
          "display_name",
          "answered"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "display_name": {
            "type": "string"
          },
          "answered": {
            "type": "boolean",
            "description": "True when the participant has at least one answered range."
          }
        }
      },
      "OverlapResults": {
        "type": "object",
        "required": [
          "current_revision",
          "calculated_revision",
          "participant_count",
          "participants",
          "candidates"
        ],
        "description": "Names per window; the planner is excluded. No range is returned as such, but every window is, so the windows and their free_participant_ids let a caller rebuild each participant's availability on the proposed days in 30-minute steps.",
        "properties": {
          "current_revision": {
            "type": "integer",
            "format": "int32"
          },
          "calculated_revision": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32",
            "description": "Null before the first calculation. The candidates are current only when it equals current_revision."
          },
          "participant_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Non-planner participants with at least one answered range."
          },
          "participants": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OverlapParticipant"
            },
            "description": "Every non-planner participant, ordered by lowercase display name."
          },
          "candidates": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OverlapCandidate"
            },
            "description": "Ordered by free_count descending, then windows inside the proposed ranges before windows outside them, then longer, earlier date, earlier start. Windows are split at proposed range edges. Windows shorter than 60 minutes are left out, except a short window with a higher free_count than every window of 60 minutes or more, and except when no window reaches 60 minutes."
          }
        }
      },
      "UpdateConfigurationRequest": {
        "type": "object",
        "required": [
          "allowed_categories",
          "base_revision"
        ],
        "properties": {
          "allowed_categories": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ActivityCategory"
            },
            "description": "Distinct categories. Empty marks the activity as decided."
          },
          "base_revision": {
            "type": "integer",
            "format": "int64"
          }
        }
      },
      "ConfigurationOk": {
        "type": "object",
        "required": [
          "status",
          "revision",
          "allowed_categories"
        ],
        "properties": {
          "status": {
            "const": "ok"
          },
          "revision": {
            "type": "integer",
            "format": "int64"
          },
          "allowed_categories": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ActivityCategory"
            }
          }
        }
      },
      "DuplicateCategories": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "const": "duplicate_categories"
          }
        }
      },
      "ActivityPreferenceInput": {
        "type": "object",
        "required": [
          "categories",
          "no_preference",
          "custom_suggestion",
          "configuration_revision"
        ],
        "properties": {
          "categories": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ActivityCategory"
            }
          },
          "no_preference": {
            "type": "boolean"
          },
          "custom_suggestion": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500
          },
          "configuration_revision": {
            "type": "integer",
            "format": "int64",
            "description": "The configuration revision last read."
          }
        }
      },
      "ActivityPreferenceOk": {
        "type": "object",
        "required": [
          "status",
          "participant_id",
          "categories",
          "no_preference",
          "custom_suggestion"
        ],
        "properties": {
          "status": {
            "const": "ok"
          },
          "participant_id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "categories": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ActivityCategory"
            }
          },
          "no_preference": {
            "type": "boolean"
          },
          "custom_suggestion": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "InvalidCategories": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "const": "invalid_categories"
          }
        },
        "description": "A category is not offered by the plan's configuration."
      },
      "CustomSuggestionTooLong": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "const": "custom_suggestion_too_long"
          }
        }
      },
      "GroupActivityPreferences": {
        "type": "object",
        "required": [
          "aggregate",
          "no_preference_count",
          "individual"
        ],
        "properties": {
          "aggregate": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "category",
                "count"
              ],
              "properties": {
                "category": {
                  "$ref": "#/components/schemas/ActivityCategory"
                },
                "count": {
                  "type": "integer",
                  "minimum": 0
                }
              }
            }
          },
          "no_preference_count": {
            "type": "integer",
            "minimum": 0
          },
          "individual": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "participant_id",
                "display_name",
                "categories",
                "no_preference",
                "custom_suggestion"
              ],
              "properties": {
                "participant_id": {
                  "$ref": "#/components/schemas/Uuid"
                },
                "display_name": {
                  "type": "string"
                },
                "categories": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ActivityCategory"
                  }
                },
                "no_preference": {
                  "type": "boolean"
                },
                "custom_suggestion": {
                  "type": [
                    "string",
                    "null"
                  ]
                }
              }
            }
          }
        }
      },
      "RenameRequest": {
        "type": "object",
        "required": [
          "display_name",
          "base_revision"
        ],
        "properties": {
          "display_name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 150,
            "pattern": "\\S"
          },
          "base_revision": {
            "type": "integer",
            "format": "int64",
            "description": "The display_name_revision last read from the workspace."
          }
        }
      },
      "RenameOk": {
        "type": "object",
        "required": [
          "status",
          "participant_id",
          "display_name",
          "revision"
        ],
        "properties": {
          "status": {
            "const": "ok"
          },
          "participant_id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "display_name": {
            "type": "string"
          },
          "revision": {
            "type": "integer",
            "format": "int64"
          }
        }
      },
      "DuplicateName": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "const": "duplicate_name"
          }
        }
      },
      "InvalidName": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "const": "invalid_name"
          }
        },
        "description": "Empty after trimming, or longer than 150 characters."
      },
      "CalendarConnected": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "const": "connected"
          }
        }
      },
      "CalendarDisconnected": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "const": "disconnected"
          }
        }
      },
      "CalendarUnavailable": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "const": "unavailable"
          }
        },
        "description": "Calendar assistance is not configured on this deployment, or the provider failed."
      },
      "AuthorizationRequired": {
        "type": "object",
        "required": [
          "status",
          "authorization_url"
        ],
        "properties": {
          "status": {
            "const": "authorization_required"
          },
          "authorization_url": {
            "type": "string",
            "format": "uri",
            "description": "Google consent URL. A person must open it in a browser."
          }
        }
      },
      "FreeBusyRequest": {
        "type": "object",
        "required": [
          "time_min",
          "time_max"
        ],
        "properties": {
          "time_min": {
            "type": "string",
            "format": "date-time"
          },
          "time_max": {
            "type": "string",
            "format": "date-time",
            "description": "At most 31 days after time_min."
          }
        }
      },
      "BusyBlock": {
        "type": "object",
        "required": [
          "id",
          "start_time",
          "end_time",
          "classification"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Opaque; stable only within this response."
          },
          "start_time": {
            "type": "string",
            "format": "date-time"
          },
          "end_time": {
            "type": "string",
            "format": "date-time"
          },
          "classification": {
            "type": "string",
            "enum": [
              "blocking",
              "non_blocking"
            ]
          }
        }
      },
      "FreeBusyConnected": {
        "type": "object",
        "required": [
          "status",
          "blocks"
        ],
        "properties": {
          "status": {
            "const": "connected"
          },
          "blocks": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BusyBlock"
            }
          }
        }
      },
      "ClassifyRequest": {
        "type": "object",
        "required": [
          "blocks"
        ],
        "properties": {
          "blocks": {
            "type": "array",
            "maxItems": 200,
            "items": {
              "type": "object",
              "required": [
                "id",
                "classification"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "description": "A block id from this participant's free/busy response."
                },
                "classification": {
                  "type": "string",
                  "enum": [
                    "blocking",
                    "non_blocking"
                  ]
                }
              }
            }
          }
        }
      },
      "CompleteCalendarRequest": {
        "type": "object",
        "required": [
          "ticket"
        ],
        "properties": {
          "ticket": {
            "type": "string",
            "description": "One-time ticket from the OAuth return URL fragment."
          }
        }
      }
    }
  }
}
