# Rivoyage backend API contract — the iOS/Android/steward/admin clients build
# against this file, not the Go source (brief §5). Kept current from M1 on.
openapi: 3.0.3
info:
  title: Rivoyage API
  version: 0.18.0
  description: >
    Single cloud backend for the Rivoyage river-cruise service (spec v0.4).
    Surface: fleet (ships/decks/cabins), itinerary (routes/waypoint
    templates), voyages with materialised waypoints, passenger accounts and
    saved travellers, booking ingestion + two-step claim, guests, cabin
    assignments, staff management, and the manifest hot path: signed
    credentials + steward verification bundle, batched idempotent scan
    ingestion, derived presence, reconciliation, red flags, all-aboard
    deadlines and departure sign-off. Scan batches may be replayed freely —
    duplicates are swallowed by idempotency key; presence folds by client
    capture time (occurred_at) with server order as tiebreak. All timestamps are
    RFC 3339 UTC; deadline *display* is a client concern and must use
    canonical voyage time (spec §8). Localized text is a locale→string map
    with keys de/fr/en. Itinerary offsets are whole minutes relative to route
    start (START departs at offset 0; negative offsets allowed before it).
    Authentication: bearer JWTs from /v1/auth (passenger email-OTP; staff
    password login). Discovery (routes/voyages reads) and /v1/auth are
    public; ship/deck/cabin reads and voyage-scoped lists need any staff
    role (crew scoped to their ship/voyage); writes, ingestion, cabin
    assignment and staff management need admin; profiles, claim and own
    bookings need a passenger token. 401 = missing/invalid token,
    403 = wrong role/scope, 429 = OTP/claim attempt limits (ADR-0005).
servers:
  - url: https://rivoyage.1pro.gmbh
    description: Demo instance (memory store — resets on restart)
  - url: /
security:
  - bearerAuth: []
tags:
  - { name: ops, description: Operational endpoints }
  - { name: auth, description: Passenger OTP sign-in and staff login (public) }
  - { name: profiles, description: Saved travellers (passenger) }
  - { name: bookings, description: "Ingestion, two-step claim, guests" }
  - { name: accommodation, description: Cabin assignments (admin) }
  - { name: staff, description: Staff accounts and scopes (admin) }
  - { name: manifest, description: "Safety hot path: credentials, scans, presence, red flags, sign-off (staff, voyage-scoped)" }
  - { name: safety, description: Safety-video acknowledgements (legal record) }
  - { name: catering, description: Display-only menus }
  - { name: notifications, description: NotifiableEvent -> Notification audit + sweep (admin) }
  - { name: albums, description: Media albums for routes/waypoints/ships/voyages + photo uploads }
  - { name: profile-linking, description: "Match-and-suggest account linking (MLP, ADR-0005)" }
  - { name: content, description: Operator contact card (public) }
  - { name: support, description: "Passenger <-> office message thread (\"chat with us\")" }
  - { name: devices, description: Push-device registrations (FCM/APNs seam) }
  - { name: fleet, description: "Ships, decks, cabins (admin)" }
  - { name: itinerary, description: Routes and waypoint templates (admin) }
  - { name: operators, description: Tour operators and contact details }
  - { name: favourites, description: Passenger-synchronised favourite voyages }
  - { name: voyages, description: Dated sailings and their waypoints }

paths:
  /healthz:
    get:
      tags: [ops]
      security: []
      summary: Liveness probe
      responses:
        "200":
          description: Service is up
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string, example: ok } } }

  /v1/ships:
    post:
      tags: [fleet]
      summary: Create a ship
      requestBody: { $ref: "#/components/requestBodies/ShipInput" }
      responses:
        "201": { $ref: "#/components/responses/Ship" }
        "400": { $ref: "#/components/responses/Error" }
    get:
      tags: [fleet]
      summary: List ships (ordered by name)
      responses:
        "200":
          description: Ships
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Ship" } }
  /v1/ships/{shipID}:
    parameters: [{ $ref: "#/components/parameters/shipID" }]
    get:
      tags: [fleet]
      summary: Get a ship
      responses:
        "200": { $ref: "#/components/responses/Ship" }
        "404": { $ref: "#/components/responses/Error" }
    put:
      tags: [fleet]
      summary: Update a ship (full replace; use is_active=false to retire)
      requestBody: { $ref: "#/components/requestBodies/ShipInput" }
      responses:
        "200": { $ref: "#/components/responses/Ship" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /v1/ships/{shipID}/decks:
    parameters: [{ $ref: "#/components/parameters/shipID" }]
    post:
      tags: [fleet]
      summary: Add a deck to a ship
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeckInput" }
      responses:
        "201":
          description: Created deck
          content: { application/json: { schema: { $ref: "#/components/schemas/Deck" } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
    get:
      tags: [fleet]
      summary: List a ship's decks (ordered by level)
      responses:
        "200":
          description: Decks
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Deck" } }
        "404": { $ref: "#/components/responses/Error" }
  /v1/decks/{deckID}:
    parameters: [{ $ref: "#/components/parameters/deckID" }]
    get:
      tags: [fleet]
      summary: Get a deck
      responses:
        "200":
          description: Deck
          content: { application/json: { schema: { $ref: "#/components/schemas/Deck" } } }
        "404": { $ref: "#/components/responses/Error" }
    put:
      tags: [fleet]
      summary: Update a deck (parent ship is immutable)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeckInput" }
      responses:
        "200":
          description: Updated deck
          content: { application/json: { schema: { $ref: "#/components/schemas/Deck" } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /v1/decks/{deckID}/cabins:
    parameters: [{ $ref: "#/components/parameters/deckID" }]
    post:
      tags: [fleet]
      summary: Add a cabin to a deck (cabin_number unique per deck)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CabinInput" }
      responses:
        "201":
          description: Created cabin
          content: { application/json: { schema: { $ref: "#/components/schemas/Cabin" } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
    get:
      tags: [fleet]
      summary: List a deck's cabins (ordered by number)
      responses:
        "200":
          description: Cabins
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Cabin" } }
        "404": { $ref: "#/components/responses/Error" }
  /v1/cabins/{cabinID}:
    parameters: [{ $ref: "#/components/parameters/cabinID" }]
    get:
      tags: [fleet]
      summary: Get a cabin
      responses:
        "200":
          description: Cabin
          content: { application/json: { schema: { $ref: "#/components/schemas/Cabin" } } }
        "404": { $ref: "#/components/responses/Error" }
    put:
      tags: [fleet]
      summary: Update a cabin (parent deck is immutable)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CabinInput" }
      responses:
        "200":
          description: Updated cabin
          content: { application/json: { schema: { $ref: "#/components/schemas/Cabin" } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }

  /v1/operators:
    get:
      tags: [operators]
      security: []
      summary: List tour operators (ordered by name)
      responses:
        "200":
          description: Operators
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Operator" } }
    post:
      tags: [operators]
      summary: Create a tour operator
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/OperatorInput" } } }
      responses:
        "201":
          description: Created operator
          content: { application/json: { schema: { $ref: "#/components/schemas/Operator" } } }
        "400": { $ref: "#/components/responses/Error" }
  /v1/operators/{operatorID}:
    parameters:
      - { name: operatorID, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [operators]
      security: []
      summary: Get a tour operator
      responses:
        "200":
          description: Operator
          content: { application/json: { schema: { $ref: "#/components/schemas/Operator" } } }
        "404": { $ref: "#/components/responses/Error" }
    put:
      tags: [operators]
      summary: Update a tour operator
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/OperatorInput" } } }
      responses:
        "200":
          description: Updated operator
          content: { application/json: { schema: { $ref: "#/components/schemas/Operator" } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }

  /v1/routes:
    post:
      tags: [itinerary]
      summary: Create a route (a new version references its predecessor)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RouteInput" }
      responses:
        "201":
          description: Created route
          content: { application/json: { schema: { $ref: "#/components/schemas/Route" } } }
        "400": { $ref: "#/components/responses/Error" }
    get:
      tags: [itinerary]
      summary: List routes (ordered by name)
      responses:
        "200":
          description: Routes
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Route" } }
  /v1/routes/{routeID}:
    parameters: [{ $ref: "#/components/parameters/routeID" }]
    get:
      tags: [itinerary]
      summary: Get a route
      responses:
        "200":
          description: Route
          content: { application/json: { schema: { $ref: "#/components/schemas/Route" } } }
        "404": { $ref: "#/components/responses/Error" }
    put:
      tags: [itinerary]
      summary: Update a route (previous_version_id is immutable)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RouteInput" }
      responses:
        "200":
          description: Updated route
          content: { application/json: { schema: { $ref: "#/components/schemas/Route" } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /v1/routes/{routeID}/waypoint-templates:
    parameters: [{ $ref: "#/components/parameters/routeID" }]
    post:
      tags: [itinerary]
      summary: Add a waypoint template (seq unique per route)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WaypointTemplateInput" }
      responses:
        "201":
          description: Created template
          content: { application/json: { schema: { $ref: "#/components/schemas/WaypointTemplate" } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
    get:
      tags: [itinerary]
      summary: List a route's waypoint templates (ordered by seq)
      responses:
        "200":
          description: Templates
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/WaypointTemplate" } }
        "404": { $ref: "#/components/responses/Error" }
  /v1/waypoint-templates/{templateID}:
    parameters: [{ $ref: "#/components/parameters/templateID" }]
    get:
      tags: [itinerary]
      summary: Get a waypoint template
      responses:
        "200":
          description: Template
          content: { application/json: { schema: { $ref: "#/components/schemas/WaypointTemplate" } } }
        "404": { $ref: "#/components/responses/Error" }
    put:
      tags: [itinerary]
      summary: Update a waypoint template (parent route is immutable)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WaypointTemplateInput" }
      responses:
        "200":
          description: Updated template
          content: { application/json: { schema: { $ref: "#/components/schemas/WaypointTemplate" } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }

  /v1/voyages:
    post:
      tags: [voyages]
      summary: Schedule a voyage
      description: >
        Materialises the route's active waypoint templates onto the dated
        sailing (offsets + depart_utc → planned times, including each
        waypoint's all-aboard deadline). Ship and route must be active.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VoyageCreate" }
      responses:
        "201":
          description: Created voyage with materialised waypoints
          content: { application/json: { schema: { $ref: "#/components/schemas/VoyageDetail" } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
    get:
      tags: [voyages]
      summary: List voyages (ordered by departure)
      responses:
        "200":
          description: Voyages (without waypoints)
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Voyage" } }
  /v1/voyages/{voyageID}:
    parameters: [{ $ref: "#/components/parameters/voyageID" }]
    get:
      tags: [voyages]
      summary: Get a voyage with waypoints and the travel-documents flag
      responses:
        "200":
          description: Voyage detail
          content: { application/json: { schema: { $ref: "#/components/schemas/VoyageDetail" } } }
        "404": { $ref: "#/components/responses/Error" }
  /v1/voyages/{voyageID}/status:
    parameters: [{ $ref: "#/components/parameters/voyageID" }]
    put:
      tags: [voyages]
      summary: Apply a voyage state-machine transition (spec §3.4)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { $ref: "#/components/schemas/VoyageStatus" }
      responses:
        "200":
          description: Voyage after transition
          content: { application/json: { schema: { $ref: "#/components/schemas/Voyage" } } }
        "404": { $ref: "#/components/responses/Error" }
        "409":
          description: Illegal transition
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
  /v1/voyages/{voyageID}/waypoints:
    parameters: [{ $ref: "#/components/parameters/voyageID" }]
    get:
      tags: [voyages]
      summary: List a voyage's waypoints (ordered by seq)
      responses:
        "200":
          description: Waypoints
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Waypoint" } }
        "404": { $ref: "#/components/responses/Error" }

  /v1/favorites:
    get:
      tags: [favourites]
      summary: List the signed-in passenger's favourite voyage IDs
      responses:
        "200":
          description: Favourite voyage IDs
          content:
            application/json:
              schema:
                type: object
                required: [voyage_ids]
                properties:
                  voyage_ids:
                    type: array
                    items: { type: string, format: uuid }
  /v1/favorites/{voyageID}:
    parameters: [{ $ref: "#/components/parameters/voyageID" }]
    put:
      tags: [favourites]
      summary: Add a favourite voyage (idempotent)
      responses:
        "204": { description: Favourite saved }
        "404": { $ref: "#/components/responses/Error" }
    delete:
      tags: [favourites]
      summary: Remove a favourite voyage (idempotent)
      responses:
        "204": { description: Favourite removed }
  /v1/waypoints/{waypointID}:
    parameters: [{ $ref: "#/components/parameters/waypointID" }]
    get:
      tags: [voyages]
      summary: Get a waypoint
      responses:
        "200":
          description: Waypoint
          content: { application/json: { schema: { $ref: "#/components/schemas/Waypoint" } } }
        "404": { $ref: "#/components/responses/Error" }
  /v1/waypoints/{waypointID}/times:
    parameters: [{ $ref: "#/components/parameters/waypointID" }]
    put:
      tags: [voyages]
      summary: Reschedule a waypoint (crew)
      description: >
        Updates planned times, the all-aboard deadline (the return deadline —
        must lie between planned arrival and departure) and, when known,
        actual times. From M4 a planned-time change emits a SCHEDULE_CHANGE
        notification and an all-aboard change a DEADLINE_SET audit event.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WaypointTimes" }
      responses:
        "200":
          description: Updated waypoint
          content: { application/json: { schema: { $ref: "#/components/schemas/Waypoint" } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }

  /v1/auth/otp:
    post:
      tags: [auth]
      security: []
      summary: Start passenger sign-in (emails a one-time code)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties: { email: { type: string, format: email } }
      responses:
        "202": { description: Code sent (account auto-created on first contact) }
        "429": { $ref: "#/components/responses/Error" }
  /v1/auth/verify:
    post:
      tags: [auth]
      security: []
      summary: Exchange the emailed code for a passenger session token
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email: { type: string, format: email }
                code: { type: string, example: "482913" }
      responses:
        "200": { $ref: "#/components/responses/Session" }
        "401": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
  /v1/auth/magic:
    post:
      tags: [auth]
      security: []
      summary: Exchange an emailed one-tap link token for a session (spec §4.2 magic link)
      description: >
        The token arrives in the same email as the OTP and shares its
        lifetime — consuming either kills both. Single-use.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        "200": { $ref: "#/components/responses/Session" }
        "401": { $ref: "#/components/responses/Error" }

  /v1/auth/staff/login:
    post:
      tags: [auth]
      security: []
      summary: Staff password login (returns a role/scope-carrying token)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, format: password }
      responses:
        "200": { $ref: "#/components/responses/Session" }
        "401": { $ref: "#/components/responses/Error" }

  /v1/traveler-profiles:
    post:
      tags: [profiles]
      summary: Save a traveller (passenger)
      requestBody: { $ref: "#/components/requestBodies/ProfileInput" }
      responses:
        "201": { $ref: "#/components/responses/Profile" }
        "400": { $ref: "#/components/responses/Error" }
    get:
      tags: [profiles]
      summary: List own saved travellers
      responses:
        "200":
          description: Profiles
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/TravelerProfile" } }
  /v1/traveler-profiles/{profileID}:
    parameters:
      - { name: profileID, in: path, required: true, schema: { type: string, format: uuid } }
    put:
      tags: [profiles]
      summary: Update an own saved traveller
      requestBody: { $ref: "#/components/requestBodies/ProfileInput" }
      responses:
        "200": { $ref: "#/components/responses/Profile" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }

  /v1/booking-sources:
    post:
      tags: [bookings]
      summary: Register a booking source (admin)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BookingSourceInput" }
      responses:
        "201":
          description: Source
          content: { application/json: { schema: { $ref: "#/components/schemas/BookingSource" } } }
    get:
      tags: [bookings]
      summary: List booking sources (admin)
      responses:
        "200":
          description: Sources
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/BookingSource" } }
  /v1/bookings/ingest:
    post:
      tags: [bookings]
      summary: Ingest an externally sold booking (admin / source adapter)
      description: >
        Records the purchase with its REGISTERED CONTACT — the address that
        will receive claim OTPs (ADR-0005). Lands CONFIRMED.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BookingIngest" }
      responses:
        "201": { $ref: "#/components/responses/Booking" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
  /v1/bookings/claim/initiate:
    post:
      tags: [bookings]
      summary: "Claim step 1: request an OTP to the booking's registered contact"
      description: >
        The reference (record locator or OTA voucher) is treated as PUBLIC;
        possession alone never claims. Rate-limited per account (429).
        The response reveals only a masked hint of where the code went.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reference]
              properties: { reference: { type: string, example: RV1234 } }
      responses:
        "202":
          description: Challenge created; OTP sent to the registered contact
          content:
            application/json:
              schema:
                type: object
                properties:
                  challenge_id: { type: string, format: uuid }
                  sent_to_hint: { type: string, example: "b****@e******.com" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
  /v1/bookings/claim/confirm:
    post:
      tags: [bookings]
      summary: "Claim step 2: bind the booking with the OTP"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [challenge_id, code]
              properties:
                challenge_id: { type: string, format: uuid }
                code: { type: string }
      responses:
        "200": { $ref: "#/components/responses/Booking" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
  /v1/bookings:
    get:
      tags: [bookings]
      summary: List own claimed bookings (passenger)
      responses:
        "200":
          description: Bookings
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Booking" } }
  /v1/bookings/{bookingID}:
    parameters: [{ $ref: "#/components/parameters/bookingID" }]
    get:
      tags: [bookings]
      summary: Own booking with guests and cabin assignments (passenger)
      responses:
        "200":
          description: Booking detail
          content: { application/json: { schema: { $ref: "#/components/schemas/BookingDetail" } } }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /v1/bookings/{bookingID}/guests:
    parameters: [{ $ref: "#/components/parameters/bookingID" }]
    post:
      tags: [bookings]
      summary: Add a guest to an own booking (passenger)
      description: >
        Guests under 18 at the voyage's departure must reference a
        responsible adult guest on the same booking. Fields left empty are
        snapshotted from traveler_profile_id when given.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/GuestInput" }
      responses:
        "201":
          description: Guest
          content: { application/json: { schema: { $ref: "#/components/schemas/Guest" } } }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
  /v1/voyages/{voyageID}/bookings:
    parameters: [{ $ref: "#/components/parameters/voyageID" }]
    get:
      tags: [bookings]
      summary: List a voyage's bookings (staff, scope-checked)
      responses:
        "200":
          description: Bookings
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Booking" } }
        "403": { $ref: "#/components/responses/Error" }

  /v1/voyages/{voyageID}/cabin-assignments:
    parameters: [{ $ref: "#/components/parameters/voyageID" }]
    post:
      tags: [accommodation]
      summary: Assign a cabin to a booking (admin; occupancy <= berths, one booking per cabin per voyage)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CabinAssignmentInput" }
      responses:
        "201":
          description: Assignment
          content: { application/json: { schema: { $ref: "#/components/schemas/CabinAssignment" } } }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
    get:
      tags: [accommodation]
      summary: List a voyage's cabin assignments (staff, scope-checked)
      responses:
        "200":
          description: Assignments
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/CabinAssignment" } }
  /v1/cabin-assignments/{assignmentID}:
    parameters:
      - { name: assignmentID, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      tags: [accommodation]
      summary: Release a cabin assignment (admin)
      responses:
        "204": { description: Released }
        "404": { $ref: "#/components/responses/Error" }

  /v1/staff:
    post:
      tags: [staff]
      summary: Create a staff account (admin)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StaffInput" }
      responses:
        "201":
          description: Staff account
          content: { application/json: { schema: { $ref: "#/components/schemas/Staff" } } }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
    get:
      tags: [staff]
      summary: List staff accounts (admin)
      responses:
        "200":
          description: Staff
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Staff" } }


  /v1/guests/{guestID}/credential:
    parameters:
      - { name: guestID, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [manifest]
      summary: Issue a signed voyage-scoped QR credential (passenger, own booking)
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                kind: { type: string, enum: [app, printed], default: app }
      responses:
        "201":
          description: Credential incl. the QR content (base64url payload + "." + base64url signature)
          content: { application/json: { schema: { $ref: "#/components/schemas/Credential" } } }
        "403": { $ref: "#/components/responses/Error" }
  /v1/guests/{guestID}/credentials:
    parameters:
      - { name: guestID, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [manifest]
      summary: A guest's issued credentials (staff, voyage-scoped)
      description: The office discovery step before a revocation.
      responses:
        "200":
          description: Credentials (no QR content)
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Credential" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /v1/credentials/{credentialID}/revoke:
    parameters:
      - { name: credentialID, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [manifest]
      summary: Revoke a credential (admin) — appears in the next verification bundle
      responses:
        "204": { description: Revoked }
        "404": { $ref: "#/components/responses/Error" }
  /v1/voyages/{voyageID}/verification-bundle:
    parameters: [{ $ref: "#/components/parameters/voyageID" }]
    get:
      tags: [manifest]
      summary: The steward PWA offline cache (key set, revocation list, minimum manifest)
      responses:
        "200":
          description: Bundle
          content: { application/json: { schema: { $ref: "#/components/schemas/VerificationBundle" } } }
  /v1/voyages/{voyageID}/scans:
    parameters: [{ $ref: "#/components/parameters/voyageID" }]
    post:
      tags: [manifest]
      summary: Submit a batch of queued scans (idempotent per idempotency_key)
      description: >
        Each item is applied exactly once; replays return status=duplicate so
        the PWA can clear its queue. Future capture times are clamped to the
        server clock (skew_clamped=true, original kept in the event payload).
        A CHECK_IN beyond the certificated capacity is rejected; the captain
        OVERRIDE path (type=OVERRIDE, payload.to=ABOARD) remains open.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scans]
              properties:
                scans:
                  type: array
                  items: { $ref: "#/components/schemas/ScanSubmission" }
      responses:
        "200":
          description: Per-item results
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items: { $ref: "#/components/schemas/ScanResult" }
        "403": { $ref: "#/components/responses/Error" }
  /v1/voyages/{voyageID}/presence:
    parameters: [{ $ref: "#/components/parameters/voyageID" }]
    get:
      tags: [manifest]
      summary: Folded presence for the voyage (derived, never stored truth)
      responses:
        "200":
          description: Presence projection with fold anomalies
          content:
            application/json:
              schema:
                type: object
                properties:
                  voyage_id: { type: string, format: uuid }
                  aboard_count: { type: integer, description: Guests aboard (the passenger-certificate population). }
                  staff_aboard: { type: integer }
                  pob: { type: integer, description: Persons on board = guests + staff. }
                  guests:
                    type: array
                    items: { $ref: "#/components/schemas/PresenceEntry" }
                  staff:
                    type: array
                    description: Crew presence entries (§12.10).
                    items:
                      type: object
                      properties:
                        staff_id: { type: string, format: uuid }
                        state: { type: string }
                  anomalies:
                    type: array
                    items: { $ref: "#/components/schemas/Anomaly" }
  /v1/voyages/{voyageID}/waypoints/{waypointID}/reconcile:
    parameters:
      - { $ref: "#/components/parameters/voyageID" }
      - { $ref: "#/components/parameters/waypointID" }
    post:
      tags: [manifest]
      summary: Muster reconciliation (raises OVERDUE/NO_SHOW/MISSING red flags idempotently)
      responses:
        "200":
          description: Reconciliation report
          content: { application/json: { schema: { $ref: "#/components/schemas/ReconciliationReport" } } }
  /v1/voyages/{voyageID}/waypoints/{waypointID}/all-aboard:
    parameters:
      - { $ref: "#/components/parameters/voyageID" }
      - { $ref: "#/components/parameters/waypointID" }
    post:
      tags: [manifest]
      summary: Set/move the return deadline (audited as DEADLINE_SET; idempotent)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [all_aboard_utc, idempotency_key]
              properties:
                all_aboard_utc: { type: string, format: date-time }
                idempotency_key: { type: string }
      responses:
        "200":
          description: Updated waypoint
          content: { application/json: { schema: { $ref: "#/components/schemas/Waypoint" } } }
        "400": { $ref: "#/components/responses/Error" }
  /v1/voyages/{voyageID}/waypoints/{waypointID}/signoff:
    parameters:
      - { $ref: "#/components/parameters/voyageID" }
      - { $ref: "#/components/parameters/waypointID" }
    post:
      tags: [manifest]
      summary: Departure sign-off (voyage-level event, offline-queueable, idempotent)
      description: >
        The DEPARTURE_SIGNOFF event IS the record (spec v0.3); the payload
        snapshots the muster counts. captain authority derives from the
        token role (captain/admin).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [idempotency_key]
              properties:
                idempotency_key: { type: string }
                notes: { type: string }
      responses:
        "200":
          description: Reconciliation report at sign-off
          content: { application/json: { schema: { $ref: "#/components/schemas/ReconciliationReport" } } }
  /v1/voyages/{voyageID}/red-flags:
    parameters: [{ $ref: "#/components/parameters/voyageID" }]
    get:
      tags: [manifest]
      summary: Red-flag queue (filter with ?status=OPEN|RESOLVED|ESCALATED)
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [OPEN, RESOLVED, ESCALATED] } }
      responses:
        "200":
          description: Flags
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/RedFlag" } }
  /v1/red-flags/{flagID}/resolve:
    parameters:
      - { name: flagID, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [manifest]
      summary: Resolve a red flag (notes appended)
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, properties: { notes: { type: string } } }
      responses:
        "200":
          description: Flag
          content: { application/json: { schema: { $ref: "#/components/schemas/RedFlag" } } }
  /v1/red-flags/{flagID}/escalate:
    parameters:
      - { name: flagID, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [manifest]
      summary: Escalate an open red flag (hard cutoff reached)
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, properties: { notes: { type: string } } }
      responses:
        "200":
          description: Flag
          content: { application/json: { schema: { $ref: "#/components/schemas/RedFlag" } } }
        "409": { $ref: "#/components/responses/Error" }


  /v1/guests/{guestID}/safety-ack:
    parameters:
      - { name: guestID, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [safety]
      summary: Acknowledge the safety video for a guest (passenger, own booking)
      description: >
        The account holder may acknowledge for dependents (spec 12.4, product
        policy per ADR-0006). Duplicate acks for the same video version are
        idempotent. Also appends a SAFETY_ACK audit event.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [video_version]
              properties: { video_version: { type: string, example: v1.2 } }
      responses:
        "201":
          description: Ack (legal record)
          content: { application/json: { schema: { $ref: "#/components/schemas/SafetyAck" } } }
        "403": { $ref: "#/components/responses/Error" }
  /v1/voyages/{voyageID}/safety-acks:
    parameters: [{ $ref: "#/components/parameters/voyageID" }]
    get:
      tags: [safety]
      summary: List a voyage's safety acks (staff, scope-checked)
      responses:
        "200":
          description: Acks
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/SafetyAck" } }

  /v1/voyages/{voyageID}/menus:
    parameters: [{ $ref: "#/components/parameters/voyageID" }]
    get:
      tags: [catering]
      security: []
      summary: Published menus (public voyage view)
      responses:
        "200":
          description: Menus
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Menu" } }
    post:
      tags: [catering]
      summary: Create a menu (admin; one per voyage+date)
      requestBody: { $ref: "#/components/requestBodies/MenuInput" }
      responses:
        "201":
          description: Menu
          content: { application/json: { schema: { $ref: "#/components/schemas/Menu" } } }
        "409": { $ref: "#/components/responses/Error" }
  /v1/voyages/{voyageID}/menus/all:
    parameters: [{ $ref: "#/components/parameters/voyageID" }]
    get:
      tags: [catering]
      summary: All menus incl. drafts (staff)
      responses:
        "200":
          description: Menus
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Menu" } }
  /v1/menus/{menuID}:
    parameters:
      - { name: menuID, in: path, required: true, schema: { type: string, format: uuid } }
    put:
      tags: [catering]
      summary: Update a menu (admin; items are replaced)
      requestBody: { $ref: "#/components/requestBodies/MenuInput" }
      responses:
        "200":
          description: Menu
          content: { application/json: { schema: { $ref: "#/components/schemas/Menu" } } }
        "404": { $ref: "#/components/responses/Error" }

  /v1/voyages/{voyageID}/notifications:
    parameters: [{ $ref: "#/components/parameters/voyageID" }]
    get:
      tags: [notifications]
      summary: Notification audit — events with their deliveries (admin)
      responses:
        "200":
          description: Events and deliveries
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/NotifiableEventAudit" } }
  /v1/notifications/sweep:
    post:
      tags: [notifications]
      summary: Run the notification timers now (admin; the server also sweeps on RIVOYAGE_SWEEP_INTERVAL)
      description: >
        Idempotent. Emits RETURN_DUE at the configured lead and
        RETURN_OVERDUE + red flags past each waypoint's all-aboard, and the
        Reg. 1177/2010 DEPARTURE_DELAYED / VOYAGE_CANCELLED events.
      responses:
        "200":
          description: How many events were newly emitted
          content:
            application/json:
              schema: { type: object, properties: { emitted: { type: integer } } }


  /metrics:
    get:
      tags: [ops]
      security: []
      summary: Request metrics (expvar JSON — requests by status class, latency sum, in-flight)
      responses:
        "200": { description: Metrics snapshot }

  /v1/voyages/{voyageID}/events:
    parameters: [{ $ref: "#/components/parameters/voyageID" }]
    get:
      tags: [manifest]
      summary: Audit view — the raw append-only event log (staff, scope-checked)
      parameters:
        - { name: guest_id, in: query, schema: { type: string, format: uuid } }
        - { name: type, in: query, schema: { type: string } }
      responses:
        "200":
          description: Events in capture order
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/MovementEvent" } }
  /v1/guests/{guestID}/events:
    parameters:
      - { name: guestID, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [manifest]
      summary: Per-guest audit trail (staff, scope-checked)
      responses:
        "200":
          description: The guest's events in capture order
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/MovementEvent" } }


  /v1/media:
    post:
      tags: [albums]
      summary: Upload a photo/diagram (admin, multipart field "file")
      description: >
        JPEG/PNG/WebP, max 10 MiB, validated by content sniffing.
        Content-addressed and idempotent — identical bytes return the
        identical immutable URI (serve path /media/{name}, cacheable
        forever). Reference the returned uri from album assets.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        "201":
          description: Stored
          content:
            application/json:
              schema:
                type: object
                properties:
                  uri: { type: string, format: uri }
                  content_type: { type: string }
                  bytes: { type: integer }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }

  /v1/albums:
    post:
      tags: [albums]
      summary: Create an album with assets (admin)
      requestBody: { $ref: "#/components/requestBodies/AlbumInput" }
      responses:
        "201": { $ref: "#/components/responses/Album" }
    get:
      tags: [albums]
      summary: List all albums (admin)
      responses:
        "200":
          description: Albums
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Album" } }
  /v1/albums/{albumID}:
    parameters: [{ $ref: "#/components/parameters/albumID" }]
    get:
      tags: [albums]
      security: []
      summary: Get an album (public)
      responses:
        "200": { $ref: "#/components/responses/Album" }
        "404": { $ref: "#/components/responses/Error" }
    put:
      tags: [albums]
      summary: Update an album (admin; assets replaced)
      requestBody: { $ref: "#/components/requestBodies/AlbumInput" }
      responses:
        "200": { $ref: "#/components/responses/Album" }
        "404": { $ref: "#/components/responses/Error" }
  /v1/albums/{albumID}/link:
    parameters: [{ $ref: "#/components/parameters/albumID" }]
    post:
      tags: [albums]
      summary: Link the album to a ROUTE, WAYPOINT_TEMPLATE or SHIP (admin; idempotent)
      requestBody: { $ref: "#/components/requestBodies/AlbumLink" }
      responses:
        "204": { description: Linked }
  /v1/albums/{albumID}/unlink:
    parameters: [{ $ref: "#/components/parameters/albumID" }]
    post:
      tags: [albums]
      summary: Remove an album link (admin)
      requestBody: { $ref: "#/components/requestBodies/AlbumLink" }
      responses:
        "204": { description: Unlinked }
        "404": { $ref: "#/components/responses/Error" }
  /v1/voyages/{voyageID}/albums:
    parameters: [{ $ref: "#/components/parameters/voyageID" }]
    get:
      tags: [albums]
      security: []
      summary: Albums inherited from the voyage's route and ship (public)
      responses:
        "200":
          description: Albums
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Album" } }
  /v1/waypoints/{waypointID}/albums:
    parameters: [{ $ref: "#/components/parameters/waypointID" }]
    get:
      tags: [albums]
      security: []
      summary: Albums inherited from the waypoint's template (public)
      responses:
        "200":
          description: Albums
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Album" } }
  /v1/routes/{routeID}/albums:
    parameters: [{ $ref: "#/components/parameters/routeID" }]
    get:
      tags: [albums]
      security: []
      summary: A route's albums (public)
      responses:
        "200":
          description: Albums
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Album" } }

  /v1/content/contact:
    get:
      tags: [content]
      security: []
      summary: Operator contact card (public)
      responses:
        "200":
          description: Contact
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ContactInfo" }

  /v1/support/messages:
    get:
      tags: [support]
      summary: Own support thread, oldest-first (passenger)
      responses:
        "200":
          description: Messages
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/SupportMessage" } }
        "401": { $ref: "#/components/responses/Error" }
    post:
      tags: [support]
      summary: Send a message to the office (passenger)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SupportSend" }
      responses:
        "201":
          description: Message
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SupportMessage" }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
  /v1/support/threads:
    get:
      tags: [support]
      summary: Office inbox - every active thread, most recent first (admin)
      responses:
        "200":
          description: Threads
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/SupportThread" } }
        "403": { $ref: "#/components/responses/Error" }
  /v1/support/threads/{accountID}:
    parameters:
      - { name: accountID, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [support]
      summary: One account's thread (admin)
      responses:
        "200":
          description: Messages
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/SupportMessage" } }
        "403": { $ref: "#/components/responses/Error" }
  /v1/support/threads/{accountID}/reply:
    parameters:
      - { name: accountID, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [support]
      summary: Reply to a passenger thread (admin)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SupportSend" }
      responses:
        "201":
          description: Message
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SupportMessage" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }

  /v1/devices:
    post:
      tags: [devices]
      summary: Register the signed-in account's push token (passenger)
      description: >
        Idempotent by token; a token re-registered from another account is
        taken over (a device changed hands). Routing consumes registrations
        once a real FCM/APNs project is configured.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platform, token]
              properties:
                platform: { type: string, enum: [ANDROID, IOS] }
                token: { type: string, maxLength: 4096 }
      responses:
        "204": { description: Registered }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
  /v1/devices/{token}:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    delete:
      tags: [devices]
      summary: Remove the account's registration of a token (sign-out)
      responses:
        "204": { description: Removed }
        "404": { $ref: "#/components/responses/Error" }

  /v1/notifications/mine:
    get:
      tags: [notifications]
      summary: Passenger notification inbox (poll-based; FCM reuses this data)
      description: >
        One item per notifiable event targeting the account or guests on its
        claimed bookings, newest first (multi-channel fan-outs deduped).
        Clients render a localized message from event_type.
      responses:
        "200":
          description: Inbox
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/InboxItem" }
        "401": { $ref: "#/components/responses/Error" }

  /v1/profile-links/suggestions:
    get:
      tags: [profile-linking]
      summary: Profiles matching the account's VERIFIED email, awaiting a decision
      description: >
        ADR-0005 guardrails — suggest-only (never auto-merge), fires only on
        the OTP-verified login email, reveals only name + the person's own
        trips. Undecided matches disappear once accepted or declined.
      responses:
        "200":
          description: Suggestions
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/LinkSuggestion" } }
  /v1/profile-links/{profileID}/accept:
    parameters: [{ $ref: "#/components/parameters/profileID" }]
    post:
      tags: [profile-linking]
      summary: Accept a suggested link (notifies the profile's creator)
      responses:
        "200": { description: "Decision recorded (status ACCEPTED)" }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
  /v1/profile-links/{profileID}/decline:
    parameters: [{ $ref: "#/components/parameters/profileID" }]
    post:
      tags: [profile-linking]
      summary: Decline a suggested link (first decision wins)
      responses:
        "200": { description: "Decision recorded (status DECLINED)" }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
  /v1/linked-trips:
    get:
      tags: [profile-linking]
      summary: Trips gained via accepted links — own participation only
      description: >
        Read-only view of voyages where a guest references a linked profile;
        the linked account may also issue that guest's credential
        (POST /v1/guests/{guestID}/credential).
      responses:
        "200":
          description: Trips
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/TripSummary" } }


components:
  parameters:
    shipID: { name: shipID, in: path, required: true, schema: { type: string, format: uuid } }
    deckID: { name: deckID, in: path, required: true, schema: { type: string, format: uuid } }
    cabinID: { name: cabinID, in: path, required: true, schema: { type: string, format: uuid } }
    routeID: { name: routeID, in: path, required: true, schema: { type: string, format: uuid } }
    templateID: { name: templateID, in: path, required: true, schema: { type: string, format: uuid } }
    voyageID: { name: voyageID, in: path, required: true, schema: { type: string, format: uuid } }
    waypointID: { name: waypointID, in: path, required: true, schema: { type: string, format: uuid } }
    albumID: { name: albumID, in: path, required: true, schema: { type: string, format: uuid } }
    profileID: { name: profileID, in: path, required: true, schema: { type: string, format: uuid } }
    bookingID: { name: bookingID, in: path, required: true, schema: { type: string, format: uuid } }

  requestBodies:
    AlbumInput:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [title]
            properties:
              title: { $ref: "#/components/schemas/LocalizedText" }
              kind: { type: string, example: ship-id }
              assets:
                type: array
                items: { $ref: "#/components/schemas/MediaAsset" }
    AlbumLink:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [owner_type, owner_id]
            properties:
              owner_type: { type: string, enum: [ROUTE, WAYPOINT_TEMPLATE, SHIP, VOYAGE] }
              owner_id: { type: string, format: uuid }
    MenuInput:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [menu_date]
            properties:
              menu_date: { type: string, format: date }
              title: { $ref: "#/components/schemas/LocalizedText" }
              is_published: { type: boolean, default: false }
              items:
                type: array
                items: { $ref: "#/components/schemas/MenuItem" }
    ProfileInput:
      required: true
      content:
        application/json:
          schema: { $ref: "#/components/schemas/TravelerProfileInput" }
    ShipInput:
      required: true
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ShipInput" }

  responses:
    Album:
      description: Album
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Album" }
    Session:
      description: Session token
      content:
        application/json:
          schema:
            type: object
            properties:
              token: { type: string }
              account_id: { type: string, format: uuid }
              email: { type: string }
              role: { type: string }
    Profile:
      description: Traveler profile
      content:
        application/json:
          schema: { $ref: "#/components/schemas/TravelerProfile" }
    Booking:
      description: Booking
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Booking" }
    Ship:
      description: Ship
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Ship" }
    Error:
      description: >
        Error envelope. code is one of validation (400, with field),
        not_found (404), conflict (409), internal (500).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorBody" }

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  schemas:
    LocalizedText:
      type: object
      description: Locale-to-text map; keys restricted to de, en, fr, it, ru.
      additionalProperties: { type: string }
      example: { de: "Balkonkabine", en: "Balcony cabin", fr: "Cabine avec balcon", it: "Cabina con balcone", ru: "Каюта с балконом" }

    ErrorBody:
      type: object
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string, enum: [validation, not_found, conflict, internal] }
            message: { type: string }
            field: { type: string }

    ShipInput:
      type: object
      required: [name, certificated_max_passengers]
      properties:
        name: { type: string }
        wifi_ssid: { type: string }
        wifi_password: { type: string }
        certificated_max_passengers:
          type: integer
          minimum: 1
          description: From the vessel certificate (ES-TRIN 19.05) — the hard aboard-count ceiling.
        is_active: { type: boolean, default: true }
    Ship:
      allOf:
        - $ref: "#/components/schemas/ShipInput"
        - type: object
          required: [id, is_active]
          properties:
            id: { type: string, format: uuid }
            is_active: { type: boolean }

    DeckInput:
      type: object
      required: [name]
      properties:
        name: { type: string }
        level_no: { type: integer }
        is_active: { type: boolean, default: true }
    Deck:
      allOf:
        - $ref: "#/components/schemas/DeckInput"
        - type: object
          required: [id, ship_id]
          properties:
            id: { type: string, format: uuid }
            ship_id: { type: string, format: uuid }

    CabinInput:
      type: object
      required: [cabin_number, berths, beds]
      properties:
        cabin_number: { type: string }
        category: { type: string }
        description: { $ref: "#/components/schemas/LocalizedText" }
        berths: { type: integer, minimum: 1, description: Saleable capacity. }
        beds: { type: integer, description: Physical sleeping places (≥ berths). }
        is_active: { type: boolean, default: true }
    Cabin:
      allOf:
        - $ref: "#/components/schemas/CabinInput"
        - type: object
          required: [id, deck_id]
          properties:
            id: { type: string, format: uuid }
            deck_id: { type: string, format: uuid }

    OperatorInput:
      type: object
      required: [name]
      properties:
        name: { type: string }
        email: { type: string, format: email }
        phone: { type: string }
        website: { type: string, format: uri }
        is_active: { type: boolean, default: true }
    Operator:
      allOf:
        - $ref: "#/components/schemas/OperatorInput"
        - type: object
          required: [id]
          properties:
            id: { type: string, format: uuid }

    RouteInput:
      type: object
      required: [river, embarkation_city, name, currency, operator_id]
      properties:
        river: { type: string, example: Rhine }
        rivers:
          type: array
          description: Canonical waterways used by the tour; river is the primary display value.
          items: { type: string }
        embarkation_city: { type: string, example: Basel }
        name: { type: string }
        name_i18n:
          $ref: '#/components/schemas/LocalizedText'
        currency: { type: string, pattern: "^[A-Z]{3}$", example: EUR }
        operator_id: { type: string, format: uuid }
        description_md: { $ref: "#/components/schemas/LocalizedText" }
        what_to_bring_required_md:
          $ref: "#/components/schemas/LocalizedText"
        what_to_bring_recommended_md:
          $ref: "#/components/schemas/LocalizedText"
        booking_url:
          type: string
          format: uri
          description: Seller CTA target (sales stay external); empty hides the CTA.
        is_active: { type: boolean, default: true }
        previous_version_id:
          type: string
          format: uuid
          description: Set at creation to version an existing route; immutable afterwards.
    Route:
      allOf:
        - $ref: "#/components/schemas/RouteInput"
        - type: object
          required: [id]
          properties:
            id: { type: string, format: uuid }

    WaypointType:
      type: string
      enum: [START, END, STOP, STOP_WITH_ALIGHTING]
      description: Route start and end are waypoints too; STOP_WITH_ALIGHTING lets guests go ashore.

    WaypointTemplateInput:
      type: object
      required: [seq, name, lat, lon, type, arrive_offset_min, depart_offset_min, all_aboard_offset_min]
      properties:
        seq: { type: integer, description: Order on the route; unique per route. }
        name: { type: string, example: Strasbourg }
        lat: { type: number, minimum: -90, maximum: 90 }
        lon: { type: number, minimum: -180, maximum: 180 }
        type: { $ref: "#/components/schemas/WaypointType" }
        arrive_offset_min:
          type: integer
          description: Whole minutes from route start (START departs at 0; negative allowed before it).
        depart_offset_min: { type: integer }
        all_aboard_offset_min:
          type: integer
          description: Materialises the waypoint's all-aboard (return) deadline; between arrive and depart.
        crosses_external_border:
          type: boolean
          default: false
          description: Leg into/out of non-Schengen territory (e.g. Danube→Serbia); drives the voyage's travel-document requirement (ADR-0006).
        boarding_allowed:
          type: boolean
          default: false
          description: Passengers may begin/join this voyage at this waypoint. START is always true.
        description_md: { $ref: "#/components/schemas/LocalizedText" }
        is_active: { type: boolean, default: true }
    WaypointTemplate:
      allOf:
        - $ref: "#/components/schemas/WaypointTemplateInput"
        - type: object
          required: [id, route_id]
          properties:
            id: { type: string, format: uuid }
            route_id: { type: string, format: uuid }

    VoyageStatus:
      type: string
      enum: [SCHEDULED, BOARDING, SAILING, AT_WAYPOINT, COMPLETED, CANCELLED]
      description: BOARDING is reused at every waypoint as the all-aboard window (spec §3.4).

    VoyageCreate:
      type: object
      required: [ship_id, route_id, depart_utc]
      properties:
        ship_id: { type: string, format: uuid }
        route_id: { type: string, format: uuid }
        depart_utc:
          type: string
          format: date-time
          description: START waypoint departure; anchors all waypoint offsets.
        display_price_cents:
          type: integer
          format: int64
          minimum: 0
          description: Display-only "from" price in the route currency; sales stay external.
    Voyage:
      type: object
      required: [id, ship_id, route_id, depart_utc, status]
      properties:
        id: { type: string, format: uuid }
        ship_id: { type: string, format: uuid }
        route_id: { type: string, format: uuid }
        depart_utc: { type: string, format: date-time }
        status: { $ref: "#/components/schemas/VoyageStatus" }
        display_price_cents: { type: integer, format: int64 }
    VoyageDetail:
      allOf:
        - $ref: "#/components/schemas/Voyage"
        - type: object
          required: [requires_travel_documents, waypoints]
          properties:
            requires_travel_documents:
              type: boolean
              description: True iff any waypoint crosses an external Schengen border (ADR-0006).
            waypoints:
              type: array
              items: { $ref: "#/components/schemas/Waypoint" }

    Waypoint:
      type: object
      required: [id, voyage_id, waypoint_template_id, seq, name, lat, lon, type,
                 crosses_external_border, boarding_allowed, planned_arrive_utc,
                 planned_depart_utc, all_aboard_utc]
      properties:
        id: { type: string, format: uuid }
        voyage_id: { type: string, format: uuid }
        waypoint_template_id: { type: string, format: uuid }
        seq: { type: integer }
        name: { type: string }
        lat: { type: number }
        lon: { type: number }
        type: { $ref: "#/components/schemas/WaypointType" }
        crosses_external_border: { type: boolean }
        boarding_allowed: { type: boolean }
        planned_arrive_utc: { type: string, format: date-time }
        planned_depart_utc: { type: string, format: date-time }
        actual_arrive_utc: { type: string, format: date-time }
        actual_depart_utc: { type: string, format: date-time }
        all_aboard_utc:
          type: string
          format: date-time
          description: The return deadline (spec v0.3) — clients display it in canonical voyage time.

    WaypointTimes:
      type: object
      required: [planned_arrive_utc, planned_depart_utc, all_aboard_utc]
      properties:
        planned_arrive_utc: { type: string, format: date-time }
        planned_depart_utc: { type: string, format: date-time }
        all_aboard_utc: { type: string, format: date-time }
        actual_arrive_utc: { type: string, format: date-time }
        actual_depart_utc: { type: string, format: date-time }

    TravelDocument:
      type: object
      description: Collected ONLY for voyages crossing an external Schengen border (ADR-0006); purged post-voyage.
      required: [type, number, issuing_country, expiry]
      properties:
        type: { type: string, enum: [passport, id_card] }
        number: { type: string }
        issuing_country: { type: string, pattern: "^[A-Z]{2}$" }
        expiry: { type: string, format: date-time }
    TravelerProfileInput:
      type: object
      required: [given_name, family_name, date_of_birth]
      properties:
        given_name: { type: string }
        family_name: { type: string }
        date_of_birth: { type: string, format: date, example: "1988-04-02" }
        nationality: { type: string, pattern: "^[A-Z]{2}$" }
        language: { type: string, enum: [de, en, fr] }
        email: { type: string, format: email }
        phone: { type: string }
        travel_document: { $ref: "#/components/schemas/TravelDocument" }
    TravelerProfile:
      allOf:
        - $ref: "#/components/schemas/TravelerProfileInput"
        - type: object
          required: [id]
          properties:
            id: { type: string, format: uuid }

    BookingSourceInput:
      type: object
      required: [kind, name]
      properties:
        kind: { type: string, enum: [OFFICE, OTA] }
        adapter: { type: string, default: native }
        name: { type: string }
        is_active: { type: boolean, default: true }
    BookingSource:
      allOf:
        - $ref: "#/components/schemas/BookingSourceInput"
        - type: object
          required: [id]
          properties:
            id: { type: string, format: uuid }

    BookingIngest:
      type: object
      required: [voyage_id, source_id, record_locator]
      properties:
        voyage_id: { type: string, format: uuid }
        source_id: { type: string, format: uuid }
        record_locator: { type: string }
        voucher_code: { type: string, description: "OTA claims; high-entropy, single-use." }
        fare_description: { type: string, example: "Deluxe, full board" }
        contact_email: { type: string, format: email, description: Receives claim OTPs. }
        contact_phone: { type: string }
    BookingStatus:
      type: string
      enum: [IMPORTED, CONFIRMED, CLAIMED, CHECKED_IN, COMPLETED, CANCELLED, NO_SHOW, EXPIRED]
    Booking:
      type: object
      required: [id, voyage_id, source_id, record_locator, status]
      properties:
        id: { type: string, format: uuid }
        voyage_id: { type: string, format: uuid }
        source_id: { type: string, format: uuid }
        record_locator: { type: string }
        status: { $ref: "#/components/schemas/BookingStatus" }
        fare_description: { type: string }
        claimed_by_account_id: { type: string, format: uuid }
    BookingDetail:
      allOf:
        - $ref: "#/components/schemas/Booking"
        - type: object
          required: [guests, cabin_assignments]
          properties:
            guests:
              type: array
              items: { $ref: "#/components/schemas/Guest" }
            cabin_assignments:
              type: array
              items: { $ref: "#/components/schemas/AssignmentDetail" }
            ship:
              $ref: "#/components/schemas/StayShip"

    AssignmentDetail:
      allOf:
        - $ref: "#/components/schemas/CabinAssignment"
        - type: object
          description: Cabin assignment resolved for passenger display.
          properties:
            cabin_number: { type: string, example: "101" }
            category: { type: string, example: balcony }
            deck_name: { type: string, example: Main Deck }

    StayShip:
      type: object
      description: >
        Passenger-visible ship block incl. the WiFi card — only on an OWN
        claimed booking, never on public discovery.
      required: [name]
      properties:
        name: { type: string }
        wifi_ssid: { type: string }
        wifi_password: { type: string }

    GuestInput:
      type: object
      properties:
        traveler_profile_id: { type: string, format: uuid }
        given_name: { type: string }
        family_name: { type: string }
        date_of_birth: { type: string, format: date }
        language: { type: string, enum: [de, en, fr] }
        phone: { type: string }
        has_app: { type: boolean }
        responsible_adult_guest_id:
          type: string
          format: uuid
          description: Required when the guest is under 18 at departure.
    Guest:
      type: object
      required: [id, booking_id, given_name, family_name, date_of_birth, has_app]
      properties:
        safety_acked:
          type: boolean
          description: Populated on the own-booking detail view.
        id: { type: string, format: uuid }
        booking_id: { type: string, format: uuid }
        given_name: { type: string }
        family_name: { type: string }
        date_of_birth: { type: string, format: date }
        language: { type: string }
        phone: { type: string }
        has_app: { type: boolean }
        responsible_adult_guest_id: { type: string, format: uuid }

    CabinAssignmentInput:
      type: object
      required: [cabin_id, booking_id, occupancy]
      properties:
        cabin_id: { type: string, format: uuid }
        booking_id: { type: string, format: uuid }
        occupancy: { type: integer, minimum: 1 }
        guest_ids:
          type: array
          items: { type: string, format: uuid }
          description: Optional berth-level links (spec 12.7 pending).
    CabinAssignment:
      allOf:
        - $ref: "#/components/schemas/CabinAssignmentInput"
        - type: object
          required: [id, voyage_id]
          properties:
            id: { type: string, format: uuid }
            voyage_id: { type: string, format: uuid }

    StaffRole:
      type: string
      enum: [steward, captain, admin]
    StaffInput:
      type: object
      required: [name, email, role, password]
      properties:
        name: { type: string }
        email: { type: string, format: email }
        role: { $ref: "#/components/schemas/StaffRole" }
        password: { type: string, minLength: 10 }
        ship_ids:
          type: array
          items: { type: string, format: uuid }
          description: Crew scope; admins are unscoped.
        voyage_ids:
          type: array
          items: { type: string, format: uuid }
        is_active: { type: boolean, default: true }
    Staff:
      type: object
      required: [id, name, email, role, is_active]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        email: { type: string, format: email }
        role: { $ref: "#/components/schemas/StaffRole" }
        ship_ids: { type: array, items: { type: string, format: uuid } }
        voyage_ids: { type: array, items: { type: string, format: uuid } }
        is_active: { type: boolean }

    Credential:
      type: object
      required: [id, guest_id, voyage_id, kind, kid, status, expires_at]
      properties:
        id: { type: string, format: uuid }
        guest_id: { type: string, format: uuid }
        voyage_id: { type: string, format: uuid }
        kind: { type: string, enum: [app, printed] }
        kid: { type: string, description: Signing-key id — select from the bundle's key set (ADR-0004). }
        status: { type: string, enum: [ACTIVE, REVOKED, EXPIRED] }
        expires_at: { type: string, format: date-time }
        qr: { type: string, description: "base64url(payload).base64url(ed25519 signature); payload carries no PII" }
    VerificationBundle:
      type: object
      properties:
        voyage_id: { type: string, format: uuid }
        public_keys:
          type: object
          additionalProperties: { type: string }
          description: kid -> base64 Ed25519 public key (current + next during rotation).
        revoked_tokens:
          type: array
          items: { type: string }
        manifest:
          type: array
          items:
            type: object
            properties:
              guest_id: { type: string, format: uuid }
              booking_id: { type: string, format: uuid }
              display_name: { type: string }
        generated_at: { type: string, format: date-time }
    ScanSubmission:
      type: object
      required: [idempotency_key, type, guest_id, occurred_at]
      properties:
        idempotency_key: { type: string, description: "Client-generated, stable across replays." }
        type: { type: string, enum: [CHECK_IN, GO_ASHORE, RETURN_ABOARD, MARK_MISSING, OVERRIDE] }
        guest_id: { type: string, format: uuid, description: "The person's id: a guest, or a staff account when person_kind=STAFF." }
        person_kind:
          type: string
          enum: [GUEST, STAFF]
          description: >
            STAFF records crew presence through the same log (§12.10) —
            validated against staff accounts, kept out of the passenger
            certificate, summed into POB.
        waypoint_id: { type: string, format: uuid }
        occurred_at: { type: string, format: date-time, description: Client capture time (may be minutes old). }
        payload:
          type: object
          additionalProperties: true
          description: >
            OVERRIDE requires payload.to (a presence state) and should carry
            reason. GO_ASHORE may carry excursion=true (operator-guided tour,
            §12.12).
    ScanResult:
      type: object
      properties:
        idempotency_key: { type: string }
        status: { type: string, enum: [applied, duplicate, rejected] }
        error: { type: string, description: Present only when status=rejected. }
        warning:
          type: string
          description: >-
            Present only when status=applied but the follow-on red-flag
            resolve/raise failed; the movement event itself was still
            recorded durably.
        skew_clamped: { type: boolean }
    PresenceEntry:
      type: object
      properties:
        guest_id: { type: string, format: uuid }
        booking_id: { type: string, format: uuid }
        display_name: { type: string }
        state: { type: string, enum: [NOT_BOARDED, ABOARD, ASHORE, MISSING, LEFT_BEHIND] }
        last_occurred_at: { type: string, format: date-time }
        on_excursion: { type: boolean, description: Ashore with an operator-guided tour (§12.12). }
    Anomaly:
      type: object
      description: A fold transition whose prior state contradicted expectation — applied but surfaced.
      properties:
        event_id: { type: string }
        guest_id: { type: string, format: uuid }
        type: { type: string }
        from_state: { type: string }
        note: { type: string }
    GuestStatus:
      type: object
      properties:
        guest_id: { type: string, format: uuid }
        display_name: { type: string }
        state: { type: string }
        overdue: { type: boolean }
        on_excursion:
          type: boolean
          description: >
            Ashore with an operator-guided tour (§12.12) — the deadline is
            waitable via the guide; overdue flags carry the note.
    ReconciliationReport:
      type: object
      properties:
        voyage_id: { type: string, format: uuid }
        waypoint_id: { type: string, format: uuid }
        all_aboard_utc: { type: string, format: date-time }
        generated_at: { type: string, format: date-time }
        aboard_count: { type: integer }
        staff_aboard: { type: integer }
        pob: { type: integer, description: Persons on board = guests + staff (ES-TRIN musters all persons). }
        capacity_max: { type: integer, description: certificated_max_passengers (ES-TRIN ceiling — guests only). }
        capacity_exceeded: { type: boolean }
        aboard: { type: array, items: { $ref: "#/components/schemas/GuestStatus" } }
        ashore: { type: array, items: { $ref: "#/components/schemas/GuestStatus" } }
        missing: { type: array, items: { $ref: "#/components/schemas/GuestStatus" } }
        not_boarded: { type: array, items: { $ref: "#/components/schemas/GuestStatus" } }
        anomalies: { type: array, items: { $ref: "#/components/schemas/Anomaly" } }
    RedFlag:
      type: object
      properties:
        id: { type: string, format: uuid }
        voyage_id: { type: string, format: uuid }
        guest_id: { type: string, format: uuid }
        waypoint_id: { type: string, format: uuid }
        reason: { type: string, enum: [OVERDUE, NO_SHOW, MISSING] }
        status: { type: string, enum: [OPEN, RESOLVED, ESCALATED] }
        notes: { type: string }
        raised_at: { type: string, format: date-time }
        resolved_at: { type: string, format: date-time }

    SafetyAck:
      type: object
      properties:
        id: { type: string, format: uuid }
        guest_id: { type: string, format: uuid }
        voyage_id: { type: string, format: uuid }
        video_version: { type: string }
        ack_by_account_id: { type: string, format: uuid }
        acked_at: { type: string, format: date-time }
    MenuItem:
      type: object
      properties:
        course: { type: string, example: main }
        name: { $ref: "#/components/schemas/LocalizedText" }
        description: { $ref: "#/components/schemas/LocalizedText" }
        sort: { type: integer }
    Menu:
      type: object
      properties:
        id: { type: string, format: uuid }
        voyage_id: { type: string, format: uuid }
        menu_date: { type: string, format: date }
        title: { $ref: "#/components/schemas/LocalizedText" }
        is_published: { type: boolean }
        items:
          type: array
          items: { $ref: "#/components/schemas/MenuItem" }
    NotifiableEventAudit:
      type: object
      properties:
        id: { type: string, format: uuid }
        type:
          type: string
          enum: [BOOKING_CONFIRMED, BOARDING_OPEN, SAFETY_ACK_REQUIRED, ARRIVAL_SOON,
                 ALL_ABOARD_SOON, RETURN_DUE, RETURN_OVERDUE, SCHEDULE_CHANGE,
                 DEPARTURE_DELAYED, VOYAGE_CANCELLED, DEPARTURE]
        subject_ref: { type: string, format: uuid }
        emitted_at: { type: string, format: date-time }
        payload: { type: object, additionalProperties: true }
        notifications:
          type: array
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              target_kind: { type: string, enum: [guest, account] }
              target_id: { type: string, format: uuid }
              channel: { type: string, enum: [push, sms, email] }
              address: { type: string }
              status: { type: string, enum: [QUEUED, SENT, FAILED] }
              attempts: { type: integer }
              last_error: { type: string }
              sent_at: { type: string, format: date-time }
    MovementEvent:
      type: object
      description: One append-only operational fact — also the audit record (brief §8).
      properties:
        event_id: { type: string, description: Server-minted ULID (receive order). }
        voyage_id: { type: string, format: uuid }
        guest_id: { type: string, format: uuid }
        waypoint_id: { type: string, format: uuid }
        type:
          type: string
          enum: [CHECK_IN, GO_ASHORE, RETURN_ABOARD, MARK_MISSING, OVERRIDE,
                 SAFETY_ACK, DEADLINE_SET, RED_FLAG, DEPARTURE_SIGNOFF]
        occurred_at: { type: string, format: date-time, description: Client capture time (skew-clamped). }
        recorded_at: { type: string, format: date-time, description: Server receive time. }
        recorded_by: { type: string, format: uuid }
        idempotency_key: { type: string }
        payload: { type: object, additionalProperties: true }
    MediaAsset:
      type: object
      required: [type, uri]
      properties:
        type: { type: string, enum: [IMAGE, VIDEO, DIAGRAM] }
        uri: { type: string, format: uri }
        caption: { $ref: "#/components/schemas/LocalizedText" }
        sort: { type: integer }
    InboxItem:
      type: object
      required: [id, event_id, event_type, voyage_id, channel, emitted_at]
      properties:
        id: { type: string, format: uuid }
        event_id: { type: string, format: uuid }
        event_type:
          type: string
          description: NotifiableEvent type (BOOKING_CONFIRMED, RETURN_DUE, ...)
        voyage_id: { type: string, format: uuid }
        channel: { type: string, enum: [push, sms, email] }
        emitted_at: { type: string, format: date-time }

    ContactInfo:
      type: object
      required: [name, email, phone, hours]
      properties:
        name: { type: string, example: Rivoyage Guest Services }
        email: { type: string, example: service@rivoyage.example }
        phone: { type: string, example: "+41 61 000 00 00" }
        hours: { type: string, example: "Mon-Fri 08:00-18:00 CET" }

    SupportSend:
      type: object
      required: [body]
      properties:
        body: { type: string, maxLength: 4000 }

    SupportMessage:
      type: object
      required: [id, author, body, created_at]
      properties:
        id: { type: string, format: uuid }
        author: { type: string, enum: [PASSENGER, STAFF] }
        body: { type: string }
        created_at: { type: string, format: date-time }

    SupportThread:
      type: object
      required: [account_id, last_message, message_count]
      properties:
        account_id: { type: string, format: uuid }
        email: { type: string }
        last_message: { $ref: "#/components/schemas/SupportMessage" }
        message_count: { type: integer }

    Album:
      type: object
      properties:
        id: { type: string, format: uuid }
        title: { $ref: "#/components/schemas/LocalizedText" }
        kind: { type: string }
        assets:
          type: array
          items: { $ref: "#/components/schemas/MediaAsset" }
    TripSummary:
      type: object
      properties:
        booking_id: { type: string, format: uuid }
        voyage_id: { type: string, format: uuid }
        route_name: { type: string }
        river: { type: string }
        depart_utc: { type: string, format: date-time }
        guest_id: { type: string, format: uuid }
        guest_name: { type: string }
        status: { type: string }
    LinkSuggestion:
      type: object
      properties:
        profile_id: { type: string, format: uuid }
        given_name: { type: string }
        family_name: { type: string }
        trips:
          type: array
          items: { $ref: "#/components/schemas/TripSummary" }
