openapi: 3.1.0
info:
  title: Stage Stop BBQ Public API
  version: 1.0.0
  description: |
    Public HTTP endpoints for Stage Stop BBQ — health probes, form
    submissions, gift-card lookups, and analytics ingestion. Hand-written
    spec; see docs/API.md for the maintenance pattern.

    Operator-only surfaces (admin dashboard, cron jobs, Stripe webhooks)
    are intentionally omitted from this spec. They exist but are not
    contracts external consumers should rely on.
  contact:
    name: Stage Stop BBQ Operations
    email: hello@stagestopbbq.com
    url: https://stagestopbbq.com/contact
  license:
    name: Proprietary
servers:
  - url: https://stagestopbbq.com
    description: Production
  - url: http://localhost:3000
    description: Local development
tags:
  - name: health
    description: Liveness + dependency probes for uptime monitors.
  - name: gift-cards
    description: Public balance lookups.
  - name: analytics
    description: First-party funnel + search tracking ingestion.
  - name: forms
    description: Public form submission endpoints.
  - name: sms
    description: Twilio-driven SMS webhooks.
  - name: blog
    description: Structured feeds of the Stage Stop journal for headless consumers.
  - name: menu
    description: Structured feeds of the full menu for headless consumers.
  - name: menu-recommendations
    description: |
      AI-style deterministic menu recommender — no LLM, no env,
      build-time-stable mapping. Pairs with /api/menu.json for
      headless consumers.
  - name: recipes
    description: Structured feed of Chef Eve recipes for headless consumers.
  - name: locations
    description: |
      Location feeds — the full index of every Stage Stop stop
      (/api/locations.json) and per-stop live hours / is-open-now
      (/api/locations/{slug}/hours.json). The index is build-time-stable;
      the per-stop hours are computed at request time against the
      America/Denver wall clock.
  - name: holidays-closures
    description: |
      Operator-authored holiday and closure schedule. Powers the
      closure banner and the AI-agent answer to 'is Stage Stop open
      on X'.
  - name: discovery
    description: Machine-readable site feeds for AI agents + crawlers.
  - name: jobs
    description: |
      Open-positions feed for Google for Jobs, third-party job boards
      (Indeed, ZipRecruiter sponsored ingest), and MCP-shaped AI
      agents. Same source of truth as the JobPosting JSON-LD on
      /careers — src/data/jobs.ts — so the two surfaces cannot drift.
  - name: reservations
    description: |
      Public reservation surfaces. v1 exposes the customer-facing cancel
      endpoint paired with the one-click button on
      /reservations/{id}/confirmation; the create-reservation surface is
      a React Server Action invoked from /reservations and is NOT a JSON
      endpoint. See docs/RESERVATIONS.md.
  - name: admin
    description: |
      Operator-only surfaces. Cookie-session gated (httpOnly
      `admin_session`) — external API consumers cannot call these
      endpoints. Documented in the spec so the wire shape is
      discoverable for in-repo tooling (a future internal Slack
      command, the proposed status-badge embed), NOT as a public
      contract. The shapes can change without notice; do not depend
      on them from a third-party consumer.
paths:
  /api/health:
    get:
      tags: [health]
      summary: Basic liveness probe.
      description: |
        ~5ms env-presence check meant for UptimeRobot (5-minute cadence)
        or any uptime monitor that just needs a 200. Reports which
        integrations are configured but does NOT actively probe them —
        use /api/health-detailed for that.
      responses:
        '200':
          description: Server reachable. Always 200 even when integrations are missing.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
              examples:
                ready:
                  value:
                    ok: true
                    ready: true
                    missing: []
                    timestamp: '2026-06-14T12:34:56.000Z'
                missingEnv:
                  value:
                    ok: true
                    ready: false
                    missing: ['STRIPE_SECRET_KEY', 'D1_DATABASE_BINDING']
                    timestamp: '2026-06-14T12:34:56.000Z'
  /api/health-detailed:
    get:
      tags: [health]
      summary: Per-service dependency probe.
      description: |
        Heavier probe — actively pings D1, Klaviyo, and Stripe in
        parallel (each capped at a 3s AbortController timeout) and
        reports per-service ok / latency_ms / detail. Twilio reports
        env-presence only (no read-only safe endpoint to poke).
        Consumed by the /status page (60s poll) and operator dashboards.

        Schema mirrors the TypeScript HealthDetailedResponse + ServiceStatus
        types in src/lib/health.ts — those types are the source of truth.
      responses:
        '200':
          description: Probe completed. Overall status reflects the rollup.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthDetailedResponse'
  /api/health/uptime-30d.json:
    get:
      tags: [health]
      summary: Long-window uptime snapshot (30 days).
      description: |
        30-day daily rollup of D1 `health_history` — the long-window
        pair for the 24-hour SystemStatusUptime aggregate on
        /api/admin/system-status.json. Answers "how reliable has the
        site been this month" in one GET. Built for status-badge embeds
        and AI-ops queries about long-term reliability.

        Source: D1 `health_history` table, written hourly by
        /api/cron/health-snapshot (iter-38). The route calls
        getHealthHistoryDaily(30) in src/lib/db.ts, which groups the
        last 30 days of rows by date(captured_at) UTC and emits one
        row per day:
          - `healthy_pct` = AVG(healthy_count * 100.0 / total_count)
            across the day's snapshots, guarded against zero
            total_count.
          - `total_checks` = COUNT(*) of snapshots that landed that
            day. Expect 24 on a clean day; lower if the cron skipped
            hours.
          - `slowest_avg_ms` = AVG(slowest_service_ms) across the
            day's snapshots. null when every snapshot's
            slowest_service_ms was null — data gap, NOT a zero.
            Consumers should render "—" or skip the chart point.

        `aggregate_healthy_pct` is the weighted average across days
        (sum(healthy_pct * total_checks) / sum(total_checks)). Zero
        when days[] is empty. Consumers can distinguish "no data"
        from "everything broken" via the empty days[] array.

        D1-empty fallback: when the binding is missing (local
        `next dev`, preview deploys without database_id, the SQL
        query throws) the route ships an empty days[] and
        aggregate_healthy_pct = 0. Valid 200 body.

        Edge-cached for one hour (`s-maxage=3600`) with a 24-hour
        stale-while-revalidate window — the cron writes one snapshot
        per hour so finer than that thrashes the edge cache without
        revealing fresh data.
      responses:
        '200':
          description: Feed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Uptime30dResponse'
  /api/health/uptime-7d.json:
    get:
      tags: [health]
      summary: Short-window uptime snapshot (7 days).
      description: |
        7-day hourly rollup of D1 `health_history` — the short-window
        companion to /api/health/uptime-30d.json. Designed for
        status-badge embeds that want minute-stale freshness instead of
        hour-stale (10-min edge cache vs the 30d's 1-hour cache).

        Same D1 source as the 30d feed (health_history written hourly by
        /api/cron/health-snapshot per iter-38). The difference is the
        SQL aggregate granularity: this route groups by
        strftime('%Y-%m-%dT%H', captured_at) (UTC hour anchor) instead
        of date(captured_at). The route calls getHealthHistoryHourly(168)
        in src/lib/db.ts:
          - `healthy_pct` = AVG(healthy_count * 100.0 / total_count)
            across the hour's snapshots, guarded against zero
            total_count.
          - `total_checks` = COUNT(*) of snapshots that landed that
            hour. Expect 1 on a clean hour (the cron is hourly);
            lower (0 means no bucket) when the cron skipped.

        `aggregate_healthy_pct` is the weighted average across hours
        (sum(healthy_pct * total_checks) / sum(total_checks)). Zero
        when hours[] is empty. Consumers can distinguish "no data"
        from "everything broken" via the empty hours[] array.

        D1-empty fallback: when the binding is missing (local
        `next dev`, preview deploys without database_id, the SQL
        query throws) the route ships an empty hours[] and
        aggregate_healthy_pct = 0. Valid 200 body.

        Edge-cached for ten minutes (`s-maxage=600`) with a 1-hour
        stale-while-revalidate window — a status badge refreshing on
        every page-view wants minute-stale numbers, and the 30d
        sibling's hour-stale cadence is too coarse for a 7-day chip.
      responses:
        '200':
          description: Feed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Uptime7dResponse'
  /api/holidays-and-closures.json:
    get:
      tags: [holidays-closures]
      summary: Operator-authored holiday and closure schedule.
      description: |
        Operationally important feed for two consumers — AI agents
        answering "is Stage Stop open Christmas / July 4 /
        New Year's" without scraping the HTML closure banner, AND
        the site itself (a future closure-banner Server Component)
        which reads the same source manifest.

        Two windows on one payload:
          - `upcoming` — every closure in the next 90 days from
            `generated_at`. Sorted by `date` ascending. Far enough
            that an agent asking in October about Christmas gets a
            useful answer; the New Year's Day entry crosses calendar
            years naturally.
          - `past_recent` — every closure in the last 30 days from
            `generated_at`. Sorted by `date` descending. Small
            backward window for AI agents reasoning about historical
            closures ("was Stage Stop open last Easter"). The full
            ledger lives in `src/data/closures.ts` — this feed is
            not an archive.

        Edge runtime. Edge-cached for one hour with a 24-hour
        stale-while-revalidate window (`s-maxage=3600`) — same
        cadence as /api/locations.json and /api/blog/all.json. The
        manifest is build-time-stable so this is generous but safe.

        Authored by the operator in src/data/closures.ts — chef's
        call on which holidays close versus stay open on modified
        hours, not derived from a calendar table. Same posture
        /api/locations.json's hours_today field takes.
      responses:
        '200':
          description: Feed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HolidaysClosuresResponse'
  /api/gift-cards/balance:
    post:
      tags: [gift-cards]
      summary: Look up the remaining balance on a gift card.
      description: |
        Public balance check — given a 16-char gift-card code, returns
        the recipient-facing fields (status, remaining balance in whole
        USD, original amount, recipient name, expiry, redemption ledger).
        No authentication. Rate-limited per IP (10 lookups per 60s) to
        discourage code-stuffing.

        Always HTTP 200 on the happy path INCLUDING the soft-miss case —
        an unknown code returns `{ exists: false }` rather than a 404.
        The "no enumeration help" posture: a 404 would tell a scanner
        "you guessed wrong, try again"; a 200 with `exists:false`
        tells them nothing they didn't already know.

        v1 limitations: balance is binary on status (active=full,
        anything else=0), `expires_at` is always null (cards never
        expire in v1), and `redemptions` is always [] (per-redemption
        ledger lands in v2 without a contract change).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GiftCardBalanceRequest'
      responses:
        '200':
          description: |
            Lookup completed. Body is `{ exists: false }` on a miss,
            or the full recipient-facing payload on a hit.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GiftCardBalanceResponse'
        '400':
          description: Malformed JSON body or validation failure on `code`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: |
            Per-IP rate limit tripped (10 lookups per 60s). Body carries
            `retryAfterSec`; the response also sets a `Retry-After`
            header with the same value.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/reservations/{id}/cancel:
    post:
      tags: [reservations]
      summary: Customer-facing one-click cancel for a pending reservation.
      description: |
        Cancel a Cantina sit-down reservation without an admin session.
        Paired with the cancel button on /reservations/{id}/confirmation
        — the server mints an HMAC `contact_token` bound to (id,
        requested_at_iso) on render and embeds it in the page HTML; the
        button POSTs the token back to this route.

        Auth model: `contact_token` is HMAC-SHA256 over
        `${id}:${requested_at_iso}` keyed by
        RESERVATIONS_TOKEN_SECRET. Constant-time verified on the server
        via Web Crypto subtle.verify. A forged or stale token returns
        `invalid_token`. No admin session, no cookie, no Turnstile —
        the token is the entire auth signal.

        Too-late rule: cancellations are refused inside the 2hr operator-
        prep window (the cantina has already prepped the slot). The
        customer-facing copy on the button surfaces a "text us" fallback
        for the too-late case.

        Rate limit: 5 cancels per hour per IP. The HMAC keyspace (2^256)
        is the real gate; the limiter is defense-in-depth. Tripped
        responses set the `Retry-After` header alongside
        `retry_after_sec` in the JSON body.

        Operator notification: a successful cancel fires a fire-and-
        forget operator ping (form_type=`reservation_cancel`, urgency
        `normal`) so the floor-of-house can free the slot for the
        waitlist. Same pattern as the create-reservation Server Action.

        Idempotency: the `already_cancelled` discriminator echoes the
        original `cancelled_at` back so a double-click or refresh-
        then-click renders the same confirmation copy without a second
        D1 round-trip. A hand-edited admin row with NULL
        `cancelled_at` falls back to "now" server-side.

        Documented end-to-end in docs/RESERVATIONS.md → "Customer
        cancellation endpoint", which carries the curl example and the
        per-discriminator error copy.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
          description: |
            32-char hex reservation id minted at insert time
            (`lower(hex(randomblob(16)))`). Same format the
            confirmation URL carries.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CancelReservationRequest'
      responses:
        '200':
          description: |
            Cancel applied (`ok:true` with `cancelled_at`) OR row
            was already cancelled (`ok:false` + `error:
            already_cancelled`). Both arms are 200 because the customer-
            visible outcome is identical: the slot is free.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CancelReservationResponse'
        '400':
          description: |
            Malformed JSON body or missing/empty contact_token. Bundled
            under `invalid_token` to keep the no-enumeration posture
            intact (a scanner can't tell whether the body was garbage or
            the token was forged).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CancelReservationResponse'
        '401':
          description: |
            HMAC contact_token didn't verify against (id,
            requested_at_iso). Constant-time compared so timing doesn't
            leak which side mismatched.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CancelReservationResponse'
        '404':
          description: |
            No reservation matches this id. Returned before token verify
            because verifying against a non-existent row would require
            minting a phantom requested_at_iso.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CancelReservationResponse'
        '409':
          description: |
            Request landed inside the 2hr operator-prep window. The
            cantina has already prepped the slot; a free cancel would be
            a no-show in disguise. The customer can still text the
            operator to cancel by hand.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CancelReservationResponse'
        '429':
          description: |
            Per-IP rate limit tripped (5/hr). Body carries
            `retry_after_sec`; the response also sets a `Retry-After`
            header with the same value.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CancelReservationResponse'
        '500':
          description: |
            D1 binding missing, UPDATE threw, or the handler caught a
            thrown exception. The response body still matches
            CancelReservationResponse so client narrowing stays clean.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CancelReservationResponse'
  /api/blog/all.json:
    get:
      tags: [blog]
      summary: Full JSON feed of every Stage Stop journal post.
      description: |
        Structured mirror of /feed.xml for programmatic consumers — the
        future mobile app, partner widgets, MCP-shaped agents. Same
        source of truth (src/data/blog-posts.ts), one payload, no
        pagination. Sorted newest first, ties broken by slug for
        deterministic output. Edge-cached for one hour.
      responses:
        '200':
          description: Feed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BlogFeedResponse'
  /api/blog/by-tag/{tag}.json:
    get:
      tags: [blog]
      summary: JSON feed of every Stage Stop journal post tagged with a given label.
      description: |
        Tag-sliced sibling of /api/blog/all.json. Same row shape
        (BlogFeedEntry) — a consumer that already renders the full feed
        gets the tag archive for free. The envelope carries `tag` (the
        canonical display label as authored in src/data/blog-posts.ts —
        "Method", "Mug Club") and `tag_slug` (the kebab-case URL slug
        that matched) so a client fanning out to several tag feeds can
        pair responses without juggling URLs.

        JSON sibling of the /blog/tag/{tag} HTML archive — same source of
        truth (src/data/blog-posts.ts) and same tag normalization helpers
        (tagToSlug + slugToDisplayTag in src/lib/blog-archive.ts) so the
        two surfaces cannot drift.

        Sort order matches /api/blog/all.json: newest first by date with
        slug tie-break. Edge-cached for one hour with a 24-hour
        stale-while-revalidate window — same cadence as the full feed so
        both revalidate in lockstep.
      parameters:
        - in: path
          name: tag
          required: true
          schema:
            type: string
          description: |
            Kebab-case URL slug for the tag — `method`, `brisket`,
            `mug-club`. Mirrors the slug used by the HTML sibling at
            /blog/tag/{tag}. Unknown slugs return 404.
      responses:
        '200':
          description: Feed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BlogByTagFeedResponse'
        '404':
          description: No journal posts carry this tag.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/blog/by-date/{year}/{month}/feed.json:
    get:
      tags: [blog]
      summary: JSON feed of every Stage Stop journal post published in a given calendar (year, month).
      description: |
        Month-sliced sibling of /api/blog/all.json. Same row shape
        (BlogFeedEntry) — a consumer that already renders the full feed
        gets the month archive for free. The envelope carries `year`
        (4-digit calendar year) and `month` (1-12, no leading zero) so a
        client fanning out to several month feeds can pair responses
        without juggling URLs.

        JSON sibling of the /blog/{year} HTML year archive — same source
        of truth (src/data/blog-posts.ts). No HTML sibling for the
        per-month slice today; this feed is the programmatic surface for
        catalog-style consumers that want the "this month" view without
        scraping the year page.

        Sort order matches /api/blog/all.json: newest first by date with
        slug tie-break. Edge-cached for one hour with a 24-hour
        stale-while-revalidate window — same cadence as the full feed so
        every blog feed revalidates in lockstep.
      parameters:
        - in: path
          name: year
          required: true
          schema:
            type: integer
            minimum: 1000
            maximum: 9999
          description: |
            4-digit calendar year (2026, 2027, …). The route returns 404
            for any year that has no published posts.
        - in: path
          name: month
          required: true
          schema:
            type: integer
            minimum: 1
            maximum: 12
          description: |
            Calendar month 1-12, with NO leading zero. /2026/6/feed.json
            is canonical; /2026/06/feed.json returns 404 to keep one
            resource per URL.
      responses:
        '200':
          description: Feed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BlogByDateFeedResponse'
        '404':
          description: Invalid (year, month) tuple or no journal posts published in it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/blog/tag-counts.json:
    get:
      tags: [blog]
      summary: Tag-to-count map for the Stage Stop journal.
      description: |
        Lightweight feed of every active tag and its post count.
        Excludes noindex/draft slugs so a count consumer never
        over-reports draft surfaces. Same edge-cache cadence as the
        rest of the blog feed family &mdash; s-maxage=3600 with a
        24-hour stale-while-revalidate window.

        Sort order: post_count DESC, then slug ASC. Display labels
        preserve the canonical capitalization from
        src/data/blog-posts.ts.

        Cheaper than fanning out across
        /api/blog/by-tag/{tag}.json when the consumer only needs the
        counts &mdash; nav chip rails, breadcrumb badges, and MCP-shaped
        agents asking "how many brisket posts does Stage Stop have"
        get a single round-trip answer.
      responses:
        '200':
          description: Tag-counts feed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BlogTagCountsResponse'
  /api/blog/by-author/{author}/feed.json:
    get:
      tags: [blog]
      summary: JSON feed of every Stage Stop journal post written by a given author.
      description: |
        Author-sliced sibling of /api/blog/all.json. Same row shape
        (BlogFeedEntry) — a consumer that already renders the full feed
        gets the author archive for free. The envelope carries `author`
        (display name as authored in src/data/blog-posts.ts —
        "Chef Eve Natasha") and `author_slug` (the kebab-case URL slug
        that matched) so a client fanning out to several author feeds
        can pair responses without juggling URLs.

        Why this feed exists today, when the only byline is brand.chef:
        the pattern enables guest posts. When Eve invites a Mash
        Mechanix brewmaster to drop a guest column, the new byline
        shows up at a stable /api/blog/by-author/<their-slug>/feed.json
        without a code change. generateStaticParams walks the manifest
        at build, dedupes the author names via the shared
        `authorToSlug` helper in src/lib/types/blog-by-author-feed.ts,
        and pre-renders every author slug.

        URL shape uses the **two-segment** dynamic-folder pattern per
        AGENTS.md (`[author]/feed.json/route.ts`) so Next 16 typegen
        narrows `params` correctly to `Promise<{ author: string }>`.
        Canonical sibling exemplars:
        src/app/api/locations/[slug]/hours.json/route.ts and
        src/app/api/blog/by-date/[year]/[month]/feed.json/route.ts.

        404 envelope on unknown author carries `available: string[]`
        (every author slug present in the manifest) so a caller can
        self-correct in one round-trip. Same posture
        /api/menu/by-category/{category}/feed.json takes —
        Cache-Control `no-store` on the 404 path so a typo'd URL does
        not pollute the edge cache with negative entries that would
        mask the moment a new guest post adds the fixing slug.

        Sort order matches /api/blog/all.json: newest first by date
        with slug tie-break. 200-path cache:
        `public, s-maxage=3600, stale-while-revalidate=86400` — same
        1h ISR + 24h stale-while-revalidate cadence as the other blog
        feeds so they all revalidate in lockstep.
      parameters:
        - in: path
          name: author
          required: true
          schema:
            type: string
          description: |
            Kebab-case URL slug for the author — `chef-eve-natasha`
            today, and any future guest-author slug
            (e.g. `mash-mechanix-brewmaster`). Derived from the
            display name via `authorToSlug` in
            src/lib/types/blog-by-author-feed.ts — lowercase,
            NFKD-normalized, diacritics stripped, non-alphanumeric →
            hyphen. Unknown slugs return 404 with `available[]`.
      responses:
        '200':
          description: Feed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BlogByAuthorFeedResponse'
        '404':
          description: |
            No posts by that author. Body carries `available[]` —
            every author slug present in the manifest — so a caller
            can self-correct.
          content:
            application/json:
              schema:
                type: object
                required: [error, author_slug, available]
                properties:
                  error:
                    type: string
                    example: No posts by that author
                  author_slug:
                    type: string
                    example: unknown
                  available:
                    type: array
                    items:
                      type: string
                    example: [chef-eve-natasha]
  /api/menu.json:
    get:
      tags: [menu]
      summary: Full JSON feed of every menu item across categories, plus per-dish detail.
      description: |
        Structured feed of the menu board (categories + items), the
        long-form per-dish detail pages, and the sides catalog — one
        payload, no pagination. Same source-of-truth files
        (src/data/menu.ts, src/data/dishes.ts, src/data/sides.ts) so the
        feed cannot drift from the rendered /menu surface.

        `dishes_detailed[].longDescription` is truncated to the first
        two paragraphs to keep the payload under 100 KB; consumers that
        want the full essay fetch the HTML page at `dishes_detailed[].url`.

        `seasonal` is empty in v1 — the operator's curated
        seasonal-specials list (/menu/seasonal) is hand-written HTML
        today, not structured data. The shape in
        src/lib/types/menu-feed.ts is the agreed-upon contract for when
        it lands.

        Edge-cached for one hour with a 24-hour stale-while-revalidate
        window.
      responses:
        '200':
          description: Feed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MenuFeedResponse'
  /api/menu/voice-friendly.json:
    get:
      tags: [menu]
      summary: TTS-optimized menu feed for voice agents.
      description: |
        Voice-agent mirror of /api/menu.json — same items, but every
        field rendered as speakable English. Prices come out as words
        ("seventeen dollars" not "$17"), ingredient slugs get humanized
        and joined into a natural sentence ("This 14-Hour Brisket
        features brisket, beef, brioche, pickle, and cabbage."), and
        TTS-hostile punctuation (em-dashes, middle-dots, trademark
        symbols) gets stripped or replaced.

        Built for Alexa skills, Twilio voice IVR, ChatGPT voice mode,
        and Google Assistant actions that need to read the menu aloud
        without maintaining their own prosody translator. Same source
        of truth (src/data/menu.ts) so the spoken feed cannot drift
        from the rendered /menu surface.

        Flat shape (no nested categories) — a voice skill answering
        "what sandwiches do you have" filters on `category` rather
        than walking a tree. Authoring order matches /api/menu.json.

        Edge-cached for one hour with a 24-hour stale-while-revalidate
        window — same cadence as /api/menu.json so both feeds
        revalidate in lockstep.
      responses:
        '200':
          description: Feed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VoiceFriendlyMenuResponse'
  /api/menu/today.json:
    get:
      tags: [menu]
      summary: Today's availability snapshot — sold_out per slug, with predicted sell-out times.
      description: |
        AI-agent + headless-consumer answer to "what's on the menu
        RIGHT NOW at Stage Stop". One small payload that collapses
        two source surfaces:

        - src/data/menu.ts — every `MenuItem` with a defined slug is
          projected into a `MenuTodayItem` row. Items without a slug
          are skipped (no stable join key into /menu/<slug>).
        - D1 `daily_inventory` — every row for today's Denver date,
          summed across locations per slug. `available_qty` is
          `max(total - sold, 0)`; `sold_out` is true when that
          drops to 0.

        Always-on default: items missing from `daily_inventory` are
        treated as available — `available_qty` is null, `sold_out`
        is false. That matches the paper menu board posture: the
        kitchen only seeds rows for items they're rationing today
        (brisket today; ribs + pulled-pork on the roadmap).

        D1-empty fallback: when the binding is missing (local
        `next dev`, preview deploys without database_id, the SQL
        query throws) every item ships as available — equivalent to
        the always-on mainline menu. Valid 200 body.

        `estimated_sellout_time` carries the wall-clock prediction
        from `predictTodaysSelloutTime()` (e.g. `"2:45pm"`,
        America/Denver) when the kitchen has sold > 0 today AND the
        item isn't yet sold out. null otherwise. Consumers display
        verbatim.

        Edge-cached for 5 minutes (`max-age=300, s-maxage=300`)
        with a 10-minute stale-while-revalidate window — inventory
        moves fast and that's the smallest resolution that matters
        when the kitchen sells out and posts a new total.
      responses:
        '200':
          description: Feed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MenuTodayResponse'
  /api/menu/recommendations.json:
    get:
      tags: [menu-recommendations]
      summary: Deterministic AI-style menu recommender — six picks per occasion.
      description: |
        Answers "what should I order for X at Stage Stop" without an
        LLM round-trip. Reads src/data/menu.ts and runs a small
        weight-based scorer per occasion, returns the top six items
        each with a 0-100 score and a one-line reasoning string.

        Pairs with /api/menu.json for headless consumers: this route
        gives the shortlist, the full feed gives the detail. An AI
        agent asking "what should I order for a family of four at
        Stage Stop" gets a stable JSON answer here in a single GET
        — no menu walk, no client-side ranking, no token spend on a
        build-time-stable decision.

        Honest-weakness caveat: MenuItem in src/data/menu.ts does
        NOT carry serves_count / popularity / mug_club_favorite /
        travel_well today. The scorer infers each from price +
        category + tag and documents the per-occasion fallback in
        the function's own jsdoc (src/lib/menu-scorer.ts). The
        deterministic shape is the contract — when the operator
        ships a real serves_count field, the wire shape stays the
        same, only the ranking gets tighter.

        Default behaviour: missing `for=` defaults to `family-of-4`
        — the most-common agent-side ask. Invalid `for=` returns 400
        with a typed error message listing the supported values so
        the caller can self-correct in one round-trip.

        Edge-cached for 5 minutes (`s-maxage=300`) with a one-hour
        `stale-while-revalidate` window. Short TTL so a menu edit
        lands within five minutes everywhere; SWR keeps the edge
        serving valid bodies past the freshness window.
      parameters:
        - in: query
          name: for
          required: false
          schema:
            type: string
            enum:
              - family-of-4
              - lunch-under-15
              - date-night
              - mug-club
              - tailgate
            default: family-of-4
          description: |
            Occasion the recommender should score for. One of
            `family-of-4`, `lunch-under-15`, `date-night`,
            `mug-club`, `tailgate`. Missing defaults to
            `family-of-4`. Unsupported values return 400.
      responses:
        '200':
          description: Recommendation body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MenuRecommendationsResponse'
        '400':
          description: |
            Unsupported `for` query value. Body carries the
            typed error message listing every supported value.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/specials/current.json:
    get:
      tags: [menu]
      summary: Today's specials feed — recurring specials + per-truck inventory.
      description: |
        AI-agent-friendly answer to "what's the special at Stage Stop
        today". One small payload, two source surfaces:

        - `recurring_specials` — items derived from src/data/menu.ts
          tags (`Saturday only`, `Rotating`, `Truck only`,
          `Mug Club`) plus the `truckOnly` flag. Saturday-only items
          only appear when the Denver wall clock weekday is Saturday —
          a Tuesday request never promises the burnt-end tacos.
          Derived, not curated; there is no src/data/specials.ts in the
          repo today.
        - `todays_inventory` — every (item, location_slug) row in D1
          `daily_inventory` for today's Denver date, with
          `remaining = max(total - sold, 0)` pre-computed so the
          consumer doesn't re-derive.

        Empty arrays are valid: a slow day with no recurring specials
        configured + no inventory seeded still returns 200 with two
        empty lists.

        Edge-cached for 10 minutes (`s-maxage=600`) with a one-hour
        stale-while-revalidate window — specials move fast, and that
        matches the cadence /api/locations/{slug}/hours.json uses.
      responses:
        '200':
          description: Feed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SpecialsCurrentResponse'
  /api/menu-categories.json:
    get:
      tags: [menu]
      summary: Lightweight category-only feed of the Stage Stop menu.
      description: |
        Subset of /api/menu.json — every category's slug + name + title
        + short `description` (the operator blurb under the section
        title on /menu) + item count. No items, no per-dish detail, no
        sides, no seasonal. Same source of truth (src/data/menu.ts) so
        the two feeds cannot drift.

        Use this when the consumer only needs the section list — a
        mobile-app menu nav, an AI agent answering "what kinds of food
        does Stage Stop serve", a partner widget that links into
        /menu#<slug> deep anchors. Saves ~80 KB+ over /api/menu.json.

        Edge-cached for one hour with a 24-hour stale-while-revalidate
        window — same cadence as /api/menu.json so both feeds revalidate
        in lockstep.
      responses:
        '200':
          description: Feed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MenuCategoriesFeedResponse'
  /api/menu/by-category/{category}/feed.json:
    get:
      tags: [menu]
      summary: Category-filtered menu feed — every item in one MenuCategory.
      description: |
        Category-sliced sibling of /api/menu.json. Returns every item
        in one MenuCategory plus a small envelope identifying the
        category (slug, name, title, description). Same row shape
        (MenuFeedItem) as /api/menu.json — a consumer that already
        renders the full feed gets the slice for free.

        Fills the gap between /api/menu.json (full board, ~80 KB+) and
        /api/menu-categories.json (nav only — slugs + counts, no
        items). A consumer rendering ONE section as a standalone
        surface (mobile-app section view, partner widget linking into
        /menu#sandwiches, MCP-shaped agent asking "show me every
        sandwich") pays for one category instead of the whole board.

        Same source of truth (src/data/menu.ts), same authoring order
        (not alphabetized), same cache cadence (1h s-maxage, 24h SWR)
        so all three menu feeds revalidate in lockstep at the next ISR
        cycle.

        Two-segment URL shape (`[category]/feed.json`) — the dynamic
        segment lives in its own folder and the literal `.json` lives
        in the next folder, so Next 16 typegen narrows params cleanly
        (documented in AGENTS.md). The single-folder shape
        `[category].json` would confuse the param extractor.

        404 on unknown category: the body carries the unknown
        `category` echoed back plus `available: string[]` (every
        category slug in authoring order) so a caller can self-correct
        in one round-trip without re-fetching /api/menu-categories.json.
        `Cache-Control: no-store` on the 404 path keeps typo'd URLs
        from polluting the edge with stale negative entries.
      parameters:
        - in: path
          name: category
          required: true
          schema:
            type: string
          description: |
            Kebab-case category slug — `sandwiches`, `mac-bowls`,
            `plates`, `ribs`, `sides`, `sauces`, `kids`,
            `sweets`. Matches the slug used by /api/menu.json
            `categories[].slug` and the section anchor on /menu.
            Unknown slugs return 404.
      responses:
        '200':
          description: Feed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MenuByCategoryFeedResponse'
        '404':
          description: No category with that slug.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/menu/by-allergen/{allergen}/feed.json:
    get:
      tags: [menu]
      summary: Allergen-filtered menu feed — every item tagged contains true/false for one allergen.
      description: |
        Allergen-sliced sibling of /api/menu.json. Inverts the menu
        question: pick one allergen, get every plated item with a
        definitive `contains` boolean and an optional operator note.
        The primary consumer is an MCP-shaped voice agent answering
        "can I eat the brisket if I'm dairy-free" — a question where a
        wrong answer is a hospital visit.

        Inclusive-list posture: items are NOT filtered to safe-only.
        Both safe AND unsafe items are returned, tagged via the
        `contains` boolean, so a consumer can render "everything you
        can eat" AND "everything you can't" off one payload. A filtered
        possibly-empty list could mean "nothing safe" OR "the endpoint
        broke" — the inclusive list removes that ambiguity.

        Conservative default for gluten-free + dairy-free: the
        MenuItem.allergens flags in src/data/menu.ts are non-exclusive
        and conservative — when a flag is missing the kitchen has not
        promised the item is safe. This route mirrors that posture: a
        missing `glutenFree` / `dairyFree` flag → `contains:
        true` rather than false. Items with the flag explicitly set to
        `true` ship `contains: false` with notes null. Items shipping
        `contains: true` carry an operator note about swap
        availability so the diner can ask.

        Kitchen-policy posture for nut-free, sesame-free, sulfite-free:
        these aren't tracked on MenuItem.allergens because the kitchen
        doesn't stock the ingredients (no peanuts, tree nuts, or nut
        oils; no sesame seeds or oil; no added sulfites in any sauce,
        rub, or side). Every item ships `contains: false` with an
        operator note flagging chef-judgment caveats — brisket is
        post-oak / pecan smoked (pecan is a tree nut), the Cantina
        wine list carries sulfites by default and is intentionally not
        represented in this feed.

        Two-segment URL shape (`[allergen]/feed.json`) — the dynamic
        segment lives in its own folder and the literal `.json` lives
        in the next folder, so Next 16 typegen narrows params cleanly
        (documented in AGENTS.md). The single-folder shape
        `[allergen].json` would confuse the param extractor.

        404 on unsupported allergen: the body carries an `error`
        message listing the supported slugs so the caller can
        self-correct in one round-trip without consulting the spec.

        Edge runtime; 1h s-maxage + 24h SWR — same cadence as
        /api/menu.json so all menu feeds revalidate in lockstep.
      parameters:
        - in: path
          name: allergen
          required: true
          schema:
            type: string
            enum:
              - gluten-free
              - dairy-free
              - nut-free
              - sesame-free
              - sulfite-free
          description: |
            Kebab-case allergen slug. The tight union mirrors what the
            kitchen can answer for with confidence: gluten and dairy
            track per-item flags in src/data/menu.ts; nut, sesame, and
            sulfite reflect kitchen-stock policy across the board.
            Unknown slugs return 404.
      responses:
        '200':
          description: Feed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MenuByAllergenResponse'
        '404':
          description: Unsupported allergen.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/recipes.json:
    get:
      tags: [recipes]
      summary: Full JSON feed of every Chef Eve at-home recipe.
      description: |
        Structured feed of the /recipes set for programmatic consumers —
        the future mobile app, partner widgets, the Klaviyo weekly-digest
        renderer, MCP-shaped agents. Same source of truth
        (src/data/recipes.ts), one payload, no pagination. Sorted newest
        first by publishedDate, ties broken by slug for deterministic
        output. Edge-cached for one hour.

        `featured_this_week` rotates by week-of-year mod recipes.length
        — UTC anchor, not the operator-facing Denver ISO week — so the
        pick changes every Monday and stays cache-friendly for the full
        hour.
      responses:
        '200':
          description: Feed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RecipesFeedResponse'
  /api/recipes/{slug}/feed.json:
    get:
      tags: [recipes]
      summary: Per-recipe JSON detail feed.
      description: |
        AI-agent-friendly answer to "how do I cook X". One small payload
        per recipe — ingredients, numbered steps, the chef tip, plus
        timing fields parsed to minutes so an agent can set a timer
        without re-parsing "8 hours" itself. Same source of truth
        (src/data/recipes.ts) so the JSON detail and the HTML page at
        /recipes/{slug} cannot drift.

        The parsed-minutes fields (`prep_time_minutes`,
        `cook_time_minutes`, `total_time_minutes`) are nullable. The
        source authors cookTime + prepTime as human strings ("8 hours",
        "15 min (night before)", "2-4 hours"); a parse failure on a new
        string shape returns null and the human label
        (`prep_time_label`, `cook_time_label`) is still on the wire
        for the consumer to render verbatim. Range shapes pick the
        LOWER bound — agents setting a timer for "2-4 hours" should
        start at 2 and check; over-shooting is worse than re-checking.

        v1 conservative defaults: `equipment_needed` ships as `[]`
        and `difficulty` hardcodes to `home-cook`. The Recipe source
        doesn't carry these fields today and the brand voice rule
        forbids inventing copy the operator didn't write. Fields stay
        on the contract for the future when the operator authors them
        upstream — no breaking change when the data lands.

        404 on unknown slug. Edge-cached for one hour with a 24-hour
        stale-while-revalidate window — same cadence as
        /api/recipes.json so both feeds revalidate in lockstep.
      parameters:
        - in: path
          name: slug
          required: true
          schema:
            type: string
          description: |
            Kebab-case recipe slug — matches Recipe.slug in
            src/data/recipes.ts and the last path segment of the HTML
            sibling /recipes/{slug}. Unknown slugs return 404.
      responses:
        '200':
          description: Per-recipe payload.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RecipeFeedSingleResponse'
        '404':
          description: No recipe with that slug.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/jobs.json:
    get:
      tags: [jobs]
      summary: Open-positions feed — schema.org JobPosting projection of every currently-hiring role.
      description: |
        AI-agent + third-party-board ingest of every currently-open
        role at Stage Stop. Subset of the /careers page (only
        `is_active: true` rows — v1 emits every row in
        src/data/jobs.ts because the Job shape does not carry an
        is_active flag today; the spec name is the forward-looking
        contract for when the operator adds it or wires Workstream
        in Phase 4).

        Every row carries the schema.org JobPosting fields Google for
        Jobs requires — `posted_at` (datePosted),
        `valid_through` (validThrough), `employment_type`,
        `hiring_organization`, `job_location`, `base_salary`,
        `title`, `description`. Field names are
        snake_case for parity with the rest of the JSON-feed family;
        consumers building strict schema.org JSON-LD remap to
        camelCase locally.

        Edge runtime; cached for one hour with a 24-hour
        stale-while-revalidate window. `Access-Control-Allow-Origin:
        *` is intentional — the feed is public, third-party boards
        are non-browser ingest, and there is no PII on the wire
        (apply_email is already public on /careers).
        `X-Robots-Tag: noindex` keeps the raw JSON out of search
        results; Google for Jobs reads /careers (with the inline
        JSON-LD), not this mirror.

        Pairs with the inline JobPosting JSON-LD on /careers — same
        source-of-truth file (src/data/jobs.ts), same salary parsing
        heuristics, same 30-day rolling `valid_through` window. tsc
        cannot let the two surfaces drift.
      x-beta: true
      responses:
        '200':
          description: Feed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobsFeedResponse'
        '503':
          description: |
            Source manifest unavailable — reserved for the future
            Workstream-backed shape when the upstream ATS fetch fails.
            Not emitted in v1 (the source is a static TS manifest that
            cannot fail to load).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/locations.json:
    get:
      tags: [locations]
      summary: Full JSON feed of every Stage Stop location.
      description: |
        Index of every Stage Stop stop — flagship + brewery-partner
        trailers/cart — with address, phone, geo, operator-authored
        hours_week / hours_today, brewery_partner block where
        applicable, and the derived is_truck boolean for consumers
        that don't want to switch on the format enum. Same source of
        truth (src/data/locations.ts) so the feed cannot drift from
        the rendered /locations pages.

        Authoring order — flagship first, then the brewery partners in
        the order they came on. NOT alphabetized. Headless consumers
        that want alphabetical can sort by `name` on their side.

        Pairs with /api/locations/{slug}/hours.json — that route
        answers "is THIS stop open right now" with live wall-clock
        math; this route answers the upstream "where ARE the stops"
        with the static index. Two feeds, by design.

        Edge-cached for one hour with a 24-hour stale-while-revalidate
        window.
      responses:
        '200':
          description: Feed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LocationsFeedResponse'
  /api/locations/{slug}/hours.json:
    get:
      tags: [locations]
      summary: Per-location live hours + is-open-now feed.
      description: |
        Computed at request time from the operator's static hours
        schedule (`openingHoursSpec` on each Location in
        src/data/locations.ts) plus the current America/Denver wall
        clock. Returns today's open windows as human strings, the
        is_open_now boolean, and a next_open ISO 8601 timestamp when
        closed.

        AI agents asking "is Stage Stop BBQ Cantina open right now"
        can hit this directly — no HTML scrape of the TruckStatusBadge
        Server Component, no XML parse of the structured-hours JSON-LD.

        Edge-cached for 10 minutes (`s-maxage=600`) with a one-hour
        stale-while-revalidate window. 10 minutes is the smallest
        resolution that matters for an opening/closing decision; the
        worst case is answering "open" five minutes past close, and
        the TTFB savings across LLM probes is the real budget.

        Cross-midnight closures aren't represented in v1 data (Star
        Ranch closes 22:00 Fri-Sat, same calendar day). Future stops
        that close past midnight will need a small extension to the
        is_open_now rule — documented in the route handler.
      parameters:
        - in: path
          name: slug
          required: true
          schema:
            type: string
          description: |
            Location slug — `the-cantina`, `mash-mechanix`,
            `old-colorado-city`, `urban-animal-star-ranch`. Matches the
            `location` `type` entries in /api/sitemap.json.
      responses:
        '200':
          description: Live hours payload for the requested location.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LocationHoursResponse'
        '404':
          description: No location with that slug.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/funnel/track:
    post:
      tags: [analytics]
      summary: First-party funnel event ingestion.
      description: |
        Records funnel events (page view, CTA click, form start, form
        complete) tied to a visitor cookie. Used by the conversion
        analytics dashboard. Fire-and-forget — the caller does not need
        to inspect the response.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FunnelTrackRequest'
      responses:
        '200':
          description: Event accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AckResponse'
        '400':
          description: Malformed event payload.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/search/track:
    post:
      tags: [analytics]
      summary: Site-search query + click-through ingestion.
      description: |
        Records on-site search queries and which result the user clicked
        through to (if any). Feeds the "popular searches" + "zero-result
        queries" reports in the admin dashboard.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SearchTrackRequest'
      responses:
        '200':
          description: Event accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AckResponse'
        '400':
          description: Malformed event payload.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/photo-submit:
    post:
      tags: [forms]
      summary: Customer photo submission.
      description: |
        Multipart upload — customers send a photo + a short caption.
        Gated behind a Cloudflare Turnstile token to fight spam.
        Submissions land in the moderation queue; Eve reviews every one
        before it shows on the site.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/PhotoSubmitRequest'
      responses:
        '200':
          description: Photo accepted into the moderation queue.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AckResponse'
        '400':
          description: Missing fields or invalid Turnstile token.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '413':
          description: Photo exceeds the size limit.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/testimonials/submit:
    post:
      tags: [forms]
      summary: Customer testimonial submission.
      description: |
        Public-facing testimonial form. Turnstile-gated. Submissions go
        to the moderation queue; we do not cherry-pick to a 5-star
        average — the queue is reviewed in order and the operator
        decides what publishes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TestimonialSubmitRequest'
      responses:
        '200':
          description: Testimonial accepted into the moderation queue.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AckResponse'
        '400':
          description: Validation failure or invalid Turnstile token.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/sms/inbound:
    post:
      tags: [sms]
      summary: Twilio inbound-SMS webhook.
      description: |
        Twilio-only webhook — request is signed via the X-Twilio-Signature
        header and validated against TWILIO_AUTH_TOKEN. Body is
        application/x-www-form-urlencoded per Twilio's contract, NOT
        JSON. Handles keyword routing (STOP, START, MENU, HOURS, etc.)
        and replies via TwiML. External consumers should NOT call this
        endpoint directly — it will reject any request missing a valid
        Twilio signature.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/TwilioInboundRequest'
      responses:
        '200':
          description: TwiML response body.
          content:
            text/xml:
              schema:
                type: string
                example: |
                  <?xml version="1.0" encoding="UTF-8"?>
                  <Response><Message>Got it — see the menu at https://stagestopbbq.com/menu</Message></Response>
        '403':
          description: Twilio signature verification failed.
      security:
        - twilioSignature: []
  /api/feedback:
    post:
      tags: [forms]
      summary: Feedback form submission (React Server Action).
      description: |
        IMPORTANT: this endpoint is a React Server Action invoked via the
        Next.js wire format — NOT a generic JSON REST endpoint. External
        consumers cannot call it with `fetch` + JSON. Documented here
        so the surface is discoverable, but the only supported caller is
        the FeedbackForm component (src/components/forms/FeedbackForm.tsx)
        which uses React's `<form action={feedback}>` pattern.

        On success, the action redirects to /feedback/success and a
        fire-and-forget notifyOperator() call queues a Slack ping.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/FeedbackRequest'
      responses:
        '303':
          description: Redirect to /feedback/success on accepted submission.
          headers:
            Location:
              schema:
                type: string
                example: /feedback/success
        '400':
          description: Validation failure.
      x-server-action: true
  /api/admin/launch-checklist.json:
    get:
      tags: [admin]
      summary: Admin-cookie-gated JSON snapshot of the launch-checklist dashboard.
      description: |
        JSON sibling of the Server-rendered /admin/launch-checklist-status
        page. Returns the same categorised probe set the HTML page
        renders — content readiness, infrastructure bindings, SEO assets,
        observability hooks — collapsed into one wire payload for
        non-browser consumers: an AI ops query asking "is StageStop
        ready to launch?" answered without scraping the admin dashboard,
        the future mobile-app launch-status badge, a CI gate that fails
        when `overall_progress_pct` drops below a threshold.

        Admin-cookie gated (httpOnly `admin_session`). External
        consumers cannot call this endpoint — documented for in-repo
        tooling. The wire shape can change without notice; do not
        depend on it from a third-party consumer.

        Probe statuses are four-valued: `green` (clean), `yellow`
        (partial or stale), `red` (failing — blocks launch),
        `unknown` (probe couldn't run — we don't pretend to have a
        number). Consumers MUST treat `unknown` as not-green.

        `overall_progress_pct` is green-items / total-items rounded
        to one decimal. Yellow does NOT count toward green; a partial
        signal is not a finished one.

        Cache-Control: no-store — a readiness snapshot is mutable by
        definition and a CDN-cached response would hide a freshly-red
        probe from the next operator browse.
      responses:
        '200':
          description: Snapshot rendered. Empty `categories` is valid (no probes seeded).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LaunchChecklistResponse'
        '401':
          description: Admin session missing or expired.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/admin/iter-history.json:
    get:
      tags: [admin]
      summary: Admin-cookie-gated JSON feed of completed iteration metadata.
      description: |
        Structured audit trail of every closed workflow iter — module
        count, rough file-change count, key landings, and the closing
        quality state (tsc clean, lint floor, test count). Built for
        the /admin/changelog-viewer expansion and future external
        tooling (an MCP-shaped agent asking "how has Stage Stop's site
        evolved", a CI gate that fails when test_count would regress).

        Manually maintained today — entries live in
        `src/data/iteration-history.ts` and get hand-appended at iter
        close. Future automation could scan the workflow journals and
        seed the array at build time without a wire-shape change.

        `current_iter` is the in-flight iter — a hand-edited constant
        in the route handler, NOT derived from `max(iter)+1`. The
        route handler is the source of truth for what "current" means;
        if a partial iter is in flight the constant gets bumped manually
        so the wire still reads correctly even before the array has the
        new entry.

        Admin-cookie gated (httpOnly `admin_session`). Most
        /api/admin/* paths are intentionally OMITTED from this spec
        (see the header docstring) — this route is the documented
        exception alongside /api/admin/system-status.json,
        /api/admin/launch-checklist.json, and /api/competitors-summary
        .json because the wire shape is small, the consumers are
        non-browser, and the audit trail is intentionally public-ish.
        Cookie-gate is still enforced; the openapi advertisement is for
        discoverability, not authorisation.

        `iterations` is sorted ASCENDING by iter (oldest first) —
        the audit trail reads chronologically. `total_tests_now` is
        the test_count from the latest closed iter — convenience for a
        consumer that wants the headline number without walking the
        array.

        Cache-Control: public, max-age=3600 with Vary: Cookie — the
        manifest is build-time-stable so the edge can hold the body
        even though the route is cookie-gated; Vary: Cookie keeps a
        shared CDN from serving one operator's response to another.
        A bumped current_iter or appended iter entry rolls out on the
        next deploy.
      x-admin-cookie-gated: true
      responses:
        '200':
          description: Feed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IterHistoryResponse'
        '401':
          description: |
            Admin session missing or expired. Body is the shared
            ErrorResponse envelope — `{ ok: false, error:
            'unauthorized' }` — matching the sibling `.json`
            admin-feed pattern set by /api/admin/system-status.json
            and /api/admin/launch-checklist.json. Cache-Control:
            no-store on this response.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/admin/openapi-coverage.json:
    get:
      tags: [admin]
      summary: Admin-cookie-gated meta endpoint listing OpenAPI route drift-test coverage.
      description: |
        Walks every route declared in /openapi.yaml and reports
        per-route drift-test coverage status. Built for the
        launch-checklist completeness audit — the operator's answer
        to "which endpoints aren't drift-protected?" without grepping
        the test file by hand. A non-zero "uncovered" count surfaces
        here before it ships as silent drift.

        Coverage signal source: TRACKED_SCHEMAS in
        `src/lib/openapi-coverage-tracked-schemas.ts`. That set is
        asserted parity-equal to
        `Object.keys(SCHEMA_EXPECTATIONS)` in
        `tests/openapi-drift-all.test.ts` via the
        `TRACKED_SCHEMAS list parity` describe block — same drift-
        prevention mechanism the rest of the openapi work uses,
        applied to itself. A schema name added to the drift-test
        table without updating the tracked set (or vice versa) is a
        CI fail, NOT a silent miscount on this endpoint.

        Per-route shape: `{ path, method, schema_ref, has_drift_test,
        drift_test_name }`. `schema_ref` is the bare schema name
        (the last segment after `#/components/schemas/`), or null
        when the route has no JSON 200 response (Twilio webhook,
        Server Action redirect, text-only payload). `has_drift_test`
        is true when `schema_ref` is non-null AND that name appears
        in TRACKED_SCHEMAS. `drift_test_name` is
        `'openapi-drift-all.test.ts'` when covered, null otherwise.

        Admin-cookie gated (httpOnly `admin_session`). External
        consumers cannot call this endpoint — documented for in-repo
        tooling. The wire shape can change without notice; do not
        depend on it from a third-party consumer. Most
        /api/admin/* paths are intentionally OMITTED from this spec;
        this route is the documented exception alongside
        /api/admin/launch-checklist.json + /api/admin/iter-history.json
        + /api/admin/system-status.json because the wire shape is
        small and the consumers are non-browser.

        `coverage_pct` is covered_routes / total_routes rounded to
        one decimal — same rounding convention
        `LaunchChecklistResponse.overall_progress_pct` uses. Zero
        when total_routes is 0 (avoids NaN on the wire).

        Cache-Control: no-store — an ops audit endpoint wants the
        live read at the moment of the operator browse; a CDN-cached
        response would mask a freshly-added route whose coverage row
        hasn't shipped yet.
      x-admin-cookie-gated: true
      responses:
        '200':
          description: Coverage snapshot. `routes` is the full per-route ledger; `coverage_pct` is the headline number.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OpenApiCoverageResponse'
        '401':
          description: Admin session missing or expired.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/admin/system-status.json:
    get:
      tags: [admin]
      summary: Admin-cookie-gated JSON snapshot of the site-health dashboard.
      description: |
        JSON sibling of the Server-rendered /admin/site-health page.
        Subset of what the HTML page renders — the live
        /api/health-detailed probe (5s timeout, null on failure),
        last-24h traffic-inferred uptime, a recent-errors slot (0 in
        v1 — Sentry org/project token isn't wired), and per-cron
        last-success timestamps (empty array in v1 — the cron_runs
        table is a follow-up).

        Admin-cookie gated (httpOnly `admin_session`). External
        consumers cannot call this endpoint — documented for
        in-repo tooling (a future internal Slack /healthcheck command,
        the proposed status-badge embed on /status, AI ops queries
        asking "is the site healthy?"). The wire shape can change
        without notice; do not depend on it from a third-party
        consumer.

        Cache-Control: no-store — a health snapshot is mutable by
        definition and a CDN-cached response would hide outages.
      responses:
        '200':
          description: Snapshot rendered. `health` is null when the upstream probe failed or timed out.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SystemStatusResponse'
        '401':
          description: Admin session missing or expired.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/competitors-summary.json:
    get:
      tags: [admin]
      summary: Admin-cookie-gated JSON feed of tracked competitor entries.
      description: |
        JSON sibling of the /admin/competitor-tracker page. Returns the
        operator's tracked competitor set from D1 `competitors` —
        name, url, type, location, sentiment_score (operator-graded 1-5
        ladder, not derived from review stars), last_reviewed_at, the
        two tracked SERP-rank positions (`brisket colorado springs`
        and `bbq colorado springs`), and a ~200-char excerpt of the
        operator's notes truncated at a word boundary.

        Admin-cookie gated (httpOnly `admin_session`). External
        consumers cannot call this endpoint — documented for in-repo
        tooling (a future internal Slack /competitors command, the
        proposed strategy dashboard, AI agents asking "who are Stage
        Stop BBQ's competitors"). The wire shape can change without
        notice; do not depend on it from a third-party consumer.

        Most /api/admin/* paths are intentionally OMITTED from this
        spec — they're operator internals and surfacing them publicly
        would imply a contract external consumers can rely on. This
        route is the documented exception alongside
        /api/admin/system-status.json: the wire shape is small, the
        consumers are non-browser, and a non-trivial number of agents
        will ask the "who's the competition" question on Stage Stop's
        public surfaces. Cookie-gate is still enforced — the openapi
        advertisement is for discoverability, not authorisation.

        Cache-Control: no-store — competitor research is operator-
        mutable; a CDN-cached snapshot would hide a freshly-graded
        row from the next operator browse.

        SERP-rank keys (`brisket`, `bbq_co_springs`) are
        OPTIONAL — a competitor without a measurement returns the key
        as missing, not as null. Consumers MUST handle the missing-key
        shape.
      responses:
        '200':
          description: Snapshot rendered. Empty `competitors` is valid (no rows seeded, or D1 binding missing).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompetitorsSummaryResponse'
        '401':
          description: Admin session missing or expired.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/sitemap.json:
    get:
      tags: [discovery]
      summary: AI-agent-friendly JSON sitemap.
      description: |
        Returns the same URL list as /sitemap.xml in a JSON shape that
        LLM context windows parse more reliably than XML. Each entry
        carries a `type` classification (page, menu-dish, location,
        brewery, blog, event, ingredient, heat-level, recipe,
        specials-year, catering-occasion, staff) so an AI agent can
        introspect the site's information architecture without reading
        every URL.

        The JSON and XML sitemaps are assembled from one shared helper
        (`buildSitemapEntries()` in src/app/sitemap.ts) so the noindex
        filter and lastModified anchors stay in lock-step.

        Cache: 1h s-maxage with a 24h stale-while-revalidate window.
      responses:
        '200':
          description: Sitemap payload.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SitemapJsonResponse'
components:
  securitySchemes:
    twilioSignature:
      type: apiKey
      in: header
      name: X-Twilio-Signature
      description: |
        HMAC-SHA1 signature of the full webhook URL + sorted POST params
        using TWILIO_AUTH_TOKEN as the key. See Twilio's webhook
        signature docs for the exact algorithm.
  schemas:
    HealthResponse:
      type: object
      required: [ok, ready, missing, timestamp]
      properties:
        ok:
          type: boolean
          description: Always true when the route renders.
        ready:
          type: boolean
          description: True when every required env var + binding is present.
        missing:
          type: array
          items:
            type: string
          description: Names of env vars or bindings that are unset.
        timestamp:
          type: string
          format: date-time
    HealthDetailedResponse:
      type: object
      description: |
        Mirrors HealthDetailedResponse in src/lib/health.ts. Keep this
        schema in sync manually when the TypeScript type changes.
      required: [status, services, timestamp]
      properties:
        status:
          type: string
          enum: [ok, degraded, down]
        services:
          type: object
          required: [d1, klaviyo, stripe, twilio, workers]
          properties:
            d1:
              $ref: '#/components/schemas/ServiceStatus'
            klaviyo:
              $ref: '#/components/schemas/ServiceStatus'
            stripe:
              $ref: '#/components/schemas/ServiceStatus'
            twilio:
              $ref: '#/components/schemas/ServiceStatus'
            workers:
              $ref: '#/components/schemas/ServiceStatus'
        timestamp:
          type: string
          format: date-time
    ServiceStatus:
      type: object
      description: |
        Mirrors ServiceStatus in src/lib/health.ts. ok=true is healthy,
        ok=false is down with a detail string, ok=null is not configured
        or not probed (e.g. Twilio has no read-only safe endpoint).
      required: [ok, configured, last_check]
      properties:
        ok:
          type: [boolean, 'null']
        configured:
          type: boolean
        latency_ms:
          type: integer
          minimum: 0
        last_check:
          type: string
          format: date-time
        detail:
          type: string
          description: Short diagnostic — never contains secrets.
    GiftCardBalanceRequest:
      type: object
      description: |
        Request body for POST /api/gift-cards/balance. Server
        pre-normalizes input (uppercase + strip spaces and dashes)
        before validating against the strict 16-char alphabet
        [A-HJ-NP-Z2-9] — the same alphabet `mintGiftCardCode()` uses,
        omits 0/O/1/I/L for readability. Mirrors GiftCardBalanceRequest
        in src/lib/types/gift-cards.ts.
      required: [code]
      properties:
        code:
          type: string
          minLength: 16
          maxLength: 16
          pattern: '^[A-HJ-NP-Z2-9]{16}$'
          description: |
            16-char gift-card code as printed on the card or in the
            redemption email. Alphabet excludes 0/O/1/I/L; pre-normalize
            on the client is optional — the server uppercases and
            strips spaces/dashes before validation.
    GiftCardRedemptionEntry:
      type: object
      description: |
        One row in the per-card redemption ledger. Mirrors
        GiftCardRedemptionEntry in src/lib/types/gift-cards.ts. v1
        returns an empty redemptions array always; v2 will populate the
        ledger from a gift_card_redemptions table without a contract
        change. `redeemed_by_staff` is redacted to "Stage Stop staff"
        — the operator email never leaks publicly.
      required: [amount, redeemed_at, redeemed_by_staff]
      properties:
        amount:
          type: integer
          minimum: 0
          description: Redemption amount in whole USD (not cents).
        redeemed_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp the redemption was recorded.
        redeemed_by_staff:
          type: string
          description: |
            Redacted staff label, always "Stage Stop staff" — never the
            operator email.
    GiftCardBalanceResponse:
      type: object
      description: |
        Mirrors GiftCardBalanceResponse in src/lib/types/gift-cards.ts.
        `exists:false` is the soft-miss signal (HTTP 200, no other
        fields populated) — the "no enumeration help" posture documented
        on the route handler. When `exists:true` the other fields
        populate; v1 cards never expire so `expires_at` is always null,
        and v1 has no per-redemption ledger so `redemptions` is always
        an empty array.
      required: [exists]
      properties:
        exists:
          type: boolean
          description: True when the code matched a row; false on soft-miss.
        status:
          type: string
          enum: [active, redeemed, cancelled, refunded]
          description: |
            Mirrors the CHECK constraint on `gift_cards.status` in
            d1/schema.sql. Present when `exists:true`.
        balance_remaining_usd:
          type: integer
          minimum: 0
          description: |
            Whole dollars, not cents. v1 is binary on status:
            active=original_amount_usd, anything else=0.
        original_amount_usd:
          type: integer
          minimum: 0
          description: Whole dollars the buyer paid for the card.
        recipient_name:
          type: string
          description: |
            Full recipient name as the buyer entered it at checkout.
            The recipient gave this name to the buyer when they shared
            the email; not buyer PII.
        expires_at:
          type: [string, 'null']
          format: date-time
          description: |
            ISO date or null. v1 cards never expire; this field is
            always null in v1, kept on the contract so v2 expiry rules
            land without a wire-shape change.
        redemptions:
          type: array
          items:
            $ref: '#/components/schemas/GiftCardRedemptionEntry'
          description: |
            Per-redemption ledger. v1 always returns an empty array; v2
            will populate from a gift_card_redemptions table without a
            contract change.
    CancelReservationRequest:
      type: object
      description: |
        Request body for POST /api/reservations/{id}/cancel. Mirrors
        CancelReservationRequest in src/lib/types/reservations-cancel.ts.

        Single field — the HMAC `contact_token` bound to (id,
        requested_at_iso). The id is in the path, not the body. The
        token is delivered to the customer in the confirmation page
        HTML (the row's noindex robots flag keeps it off the search
        index) and, future, in the confirmation SMS/email. See
        docs/RESERVATIONS.md → "Customer cancellation endpoint" for the
        mint-side recipe.
      required: [contact_token]
      properties:
        contact_token:
          type: string
          minLength: 64
          maxLength: 64
          pattern: '^[0-9a-f]{64}$'
          description: |
            Hex-encoded (64 lowercase chars) HMAC-SHA256 digest over
            `${id}:${requested_at_iso}` keyed by
            RESERVATIONS_TOKEN_SECRET. Constant-time compared via Web
            Crypto subtle.verify.
    CancelReservationResponse:
      type: object
      description: |
        Flattened discriminated union for POST
        /api/reservations/{id}/cancel. Mirrors CancelReservationResponse
        in src/lib/types/reservations-cancel.ts — the TS side is a
        proper `{ ok:true, … } | { ok:false, error, … }` union, but
        the YAML projects every field as optional under a single
        `type: object` so the openapi-drift-all test (which walks
        flat `properties` blocks) can pin the property-set against
        the TS Extract<…, {ok:true}> shape.

        Discriminator: `ok`.
          - `ok:true` arm carries `cancelled_at` (UTC second-
            precision instant the cancel landed). The client formats
            this back to Denver wall-clock for the inline confirmation
            copy.
          - `ok:false` arm carries `error` (closed enum — see
            docs/RESERVATIONS.md for the per-code customer copy) and,
            on the `rate_limited` discriminator only,
            `retry_after_sec` (mirrors the Retry-After response
            header for non-JSON consumers).

        Only `ok` is `required`; every other field is optional on
        the wire because the arm-specific fields are conditioned on the
        discriminator. The TS Extract<…, {ok:true}> guard in
        tests/openapi-drift-all.test.ts pins the union's happy-path
        property names.
      required: [ok]
      properties:
        ok:
          type: boolean
          description: |
            Discriminator. True on a successful cancel; false on every
            failure mode (including `already_cancelled`, which the
            client surfaces as a success in copy).
        cancelled_at:
          type: string
          format: date-time
          description: |
            Present on the `ok:true` arm AND on the `ok:false` +
            `error: already_cancelled` arm (the idempotency-echo of
            the original cancel timestamp so a double-click or refresh-
            then-click renders the same confirmation copy without a
            second D1 round-trip). UTC instant the cancel landed,
            second-precision. A hand-edited admin row with NULL
            `cancelled_at` falls back to "now" server-side; client
            narrowing should still check the discriminator before
            reading.
        error:
          type: string
          enum:
            - invalid_token
            - already_cancelled
            - too_late
            - not_found
            - rate_limited
            - server_error
          description: |
            Present only on the `ok:false` arm. Closed enum — see
            docs/RESERVATIONS.md for the per-code customer copy.
        retry_after_sec:
          type: integer
          minimum: 1
          description: |
            Present only on the `error: rate_limited` arm. Mirrors
            the Retry-After response header for non-JSON consumers.
    FunnelTrackRequest:
      type: object
      required: [event, visitor_id]
      properties:
        event:
          type: string
          description: Event slug (page_view, cta_click, form_start, form_complete, etc.).
        visitor_id:
          type: string
          description: First-party visitor cookie value.
        path:
          type: string
        properties:
          type: object
          additionalProperties: true
          description: Arbitrary event metadata. Keep payloads small.
    SearchTrackRequest:
      type: object
      required: [query]
      properties:
        query:
          type: string
          maxLength: 200
        clicked_result_url:
          type: string
          description: Set when the user clicked a result. Omit for zero-result events.
        result_count:
          type: integer
          minimum: 0
    PhotoSubmitRequest:
      type: object
      required: [photo, turnstile_token]
      properties:
        photo:
          type: string
          format: binary
        caption:
          type: string
          maxLength: 280
        location:
          type: string
          description: Short location label (The Cantina, Mash Mechanix, etc.).
        turnstile_token:
          type: string
          description: Cloudflare Turnstile response token.
    TestimonialSubmitRequest:
      type: object
      required: [name, body, turnstile_token]
      properties:
        name:
          type: string
          maxLength: 120
        email:
          type: string
          format: email
          description: Optional. Used only to follow up; never published.
        body:
          type: string
          minLength: 20
          maxLength: 1200
        rating:
          type: integer
          minimum: 1
          maximum: 5
        turnstile_token:
          type: string
    TwilioInboundRequest:
      type: object
      required: [From, Body, MessageSid]
      description: |
        Twilio's standard inbound-message webhook shape. Sent as
        application/x-www-form-urlencoded, not JSON.
      properties:
        From:
          type: string
          description: E.164 phone number of the sender.
        Body:
          type: string
          description: Message text.
        MessageSid:
          type: string
        AccountSid:
          type: string
        To:
          type: string
        NumMedia:
          type: string
    FeedbackRequest:
      type: object
      required: [body]
      properties:
        name:
          type: string
        email:
          type: string
          format: email
        body:
          type: string
          minLength: 1
          maxLength: 2000
        location:
          type: string
    BlogFeedEntry:
      type: object
      description: |
        One entry in the /api/blog/all.json feed. Mirrors BlogFeedEntry
        in src/lib/types/blog-feed.ts — keep both in sync when fields
        change.
      required:
        [slug, title, excerpt, date, tags, readTime, author, url, og_image_url]
      properties:
        slug:
          type: string
          description: Kebab-case post slug, also the last path segment of url.
          example: how-we-smoke-brisket
        title:
          type: string
          description: Plain text. No markdown, no HTML.
        excerpt:
          type: string
          description: Short prose summary — same string the RSS description carries.
        date:
          type: string
          format: date
          description: ISO yyyy-mm-dd publication date.
        tags:
          type: array
          items:
            type: string
          description: Short labels. Authoring order, not alphabetized.
        readTime:
          type: string
          description: Human label like "12 min". Authored, not computed.
          example: '12 min'
        author:
          type: string
          example: Chef Eve Natasha
        url:
          type: string
          format: uri
          description: Absolute https URL to the post page.
        og_image_url:
          type: string
          format: uri
          description: Absolute https URL to the per-post OG card.
    BlogFeedResponse:
      type: object
      description: |
        Top-level body of GET /api/blog/all.json. Mirrors BlogFeedResponse
        in src/lib/types/blog-feed.ts.
      required: [posts, generated_at, total]
      properties:
        posts:
          type: array
          items:
            $ref: '#/components/schemas/BlogFeedEntry'
          description: Every published post, newest first.
        generated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp the response was built.
        total:
          type: integer
          minimum: 0
          description: posts.length convenience field.
    MenuFeedItem:
      type: object
      description: |
        One row inside MenuFeedCategory.items. Mirrors MenuFeedItem in
        src/lib/types/menu-feed.ts — keep both in sync when fields change.
        Flattens optional source fields to nullable so the JSON shape is
        uniform across items.
      required:
        [slug, name, price, description, photoLabel, photoAlt, tag, allergens, ingredients, heat_level, truck_only]
      properties:
        slug:
          type: [string, 'null']
          description: |
            URL-safe identifier. Null when the source item is unslugged.
            Matches a dishes_detailed[].slug when a detail page exists.
        name:
          type: string
          description: Full menu-board name as printed.
        price:
          type: string
          description: |
            Price string preserving source shape ("$16", "$16 sandwich
            · $22 plate"). Not normalized to cents.
        description:
          type: string
          description: Short menu-board prose, one sentence.
        photoLabel:
          type: string
          description: Designer caption shown on the placeholder card.
        photoAlt:
          type: [string, 'null']
          description: Accessibility alt text. Null when unset.
        tag:
          type: [string, 'null']
          description: '"Signature", "Truck only", "Vegetarian", etc. Null when no tag.'
        allergens:
          type: [object, 'null']
          description: |
            Chef-judgment dietary flags. Null when the source item
            leaves it unset (treat as "unknown", not "no allergens").
          properties:
            glutenFree:
              type: boolean
            dairyFree:
              type: boolean
            vegetarian:
              type: boolean
            spicy:
              type: boolean
        ingredients:
          type: array
          items:
            type: string
          description: |
            Lowercase hyphenated slugs from the canonical vocabulary in
            src/data/menu.ts. Empty array when unset.
        heat_level:
          type: [integer, 'null']
          minimum: 0
          maximum: 5
          description: 0-5 ladder. Null when unset (treat as 0).
        truck_only:
          type: boolean
          description: True when only available from the trucks.
    MenuFeedCategory:
      type: object
      description: |
        One category on the menu board. Mirrors MenuFeedCategory in
        src/lib/types/menu-feed.ts.
      required: [slug, name, title, items]
      properties:
        slug:
          type: string
          description: URL-safe identifier; matches the section anchor on /menu.
        name:
          type: string
          description: Short display name ("Sandwiches", "Mac bowls").
        title:
          type: string
          description: Long display title ("The Sandwich Section.").
        items:
          type: array
          items:
            $ref: '#/components/schemas/MenuFeedItem'
          description: Every item in this category, in authoring order.
    MenuFeedDishDetailed:
      type: object
      description: |
        Per-dish detail shape for the long-form /menu/<slug> page.
        Mirrors MenuFeedDishDetailed in src/lib/types/menu-feed.ts —
        truncates longDescription to the first two paragraphs to keep
        payload under 100 KB. Consumers wanting the full essay fetch
        the HTML page at `url`.
      required:
        [slug, name, shortName, price, priceSandwich, pricePlate, cookTimeHours, wood, rub, servedAt, availability, description, longDescription, url]
      properties:
        slug:
          type: string
        name:
          type: string
        shortName:
          type: string
        price:
          type: string
          description: Price string preserving source shape.
        priceSandwich:
          type: [integer, 'null']
          minimum: 0
          description: Sandwich SKU price in whole USD. Null when not offered.
        pricePlate:
          type: [integer, 'null']
          minimum: 0
          description: Plate SKU price in whole USD. Null when not offered.
        cookTimeHours:
          type: number
          description: Hours on the smoker.
        wood:
          type: string
        rub:
          type: string
        servedAt:
          type: array
          items:
            type: string
          description: Location slugs where the dish is served daily.
        availability:
          type: string
        description:
          type: string
          description: Single-sentence menu-board description.
        longDescription:
          type: array
          items:
            type: string
          description: |
            First two paragraphs of the long-form essay. Truncated for
            payload size — full essay is on the HTML page at `url`.
        url:
          type: string
          format: uri
          description: Absolute https URL to the dish detail page.
    MenuFeedSide:
      type: object
      description: |
        Per-side detail shape. Mirrors MenuFeedSide in
        src/lib/types/menu-feed.ts.
      required:
        [slug, name, shortName, price, priceSmall, priceLarge, description, keyIngredients, ingredients, pairsWith, heat_level, url]
      properties:
        slug:
          type: string
        name:
          type: string
        shortName:
          type: string
        price:
          type: string
        priceSmall:
          type: [integer, 'null']
          minimum: 0
        priceLarge:
          type: [integer, 'null']
          minimum: 0
        description:
          type: string
        keyIngredients:
          type: array
          items:
            type: string
          description: Marketing chip list (NOT the filter vocabulary).
        ingredients:
          type: array
          items:
            type: string
          description: |
            Lowercase hyphenated filter slugs, shared vocabulary with
            MenuFeedItem.ingredients.
        pairsWith:
          type: array
          items:
            type: string
          description: Dish slugs this side is built to pair with.
        heat_level:
          type: [integer, 'null']
          minimum: 0
          maximum: 5
        url:
          type: string
          format: uri
          description: |
            Absolute https URL. Today points at /menu/sides (the
            index) — per-side pages are a follow-up.
    MenuFeedSeasonalItem:
      type: object
      description: |
        Seasonal special shape — reserved for the curated rotation.
        Empty in v1 because the operator's seasonal-specials list is
        hand-written HTML today. Contract for when it becomes
        structured data. Mirrors MenuFeedSeasonalItem in
        src/lib/types/menu-feed.ts.
      required: [slug, name, description, available_from, available_until, url]
      properties:
        slug:
          type: string
        name:
          type: string
        description:
          type: string
        available_from:
          type: [string, 'null']
          format: date
          description: ISO yyyy-mm-dd inclusive start date. Null when open-ended.
        available_until:
          type: [string, 'null']
          format: date
          description: ISO yyyy-mm-dd inclusive end date. Null when open-ended.
        url:
          type: string
          format: uri
    MenuFeedResponse:
      type: object
      description: |
        Top-level body of GET /api/menu.json. Mirrors MenuFeedResponse
        in src/lib/types/menu-feed.ts.
      required: [categories, dishes_detailed, sides, seasonal, generated_at, base_url]
      properties:
        categories:
          type: array
          items:
            $ref: '#/components/schemas/MenuFeedCategory'
          description: |
            The menu board, in section order (sandwiches, mac-bowls,
            plates, ribs, sides, sauces, kids, sweets).
        dishes_detailed:
          type: array
          items:
            $ref: '#/components/schemas/MenuFeedDishDetailed'
          description: |
            Long-form per-dish data backing /menu/<slug> landing pages.
            Subset of categories items — only dishes with a detail page
            appear here. Join key is slug.
        sides:
          type: array
          items:
            $ref: '#/components/schemas/MenuFeedSide'
          description: Every side from src/data/sides.ts.
        seasonal:
          type: array
          items:
            $ref: '#/components/schemas/MenuFeedSeasonalItem'
          description: Current rotating specials. Empty in v1.
        generated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp the response was built.
        base_url:
          type: string
          format: uri
          description: Absolute base URL all url fields are built from.
    VoiceFriendlyMenuItem:
      type: object
      description: |
        One row in VoiceFriendlyMenuResponse.items. Mirrors
        VoiceFriendlyMenuItem in src/lib/types/voice-menu.ts — keep
        both in sync when fields change. Every field is pre-rendered
        for TTS prosody: prices as words, ingredient lists as natural
        sentences, em-dashes replaced with commas, trademark symbols
        stripped.
      required:
        [slug, name, price_spoken, description_spoken, category, tag_spoken, ingredients_spoken, heat_spoken, truck_only]
      properties:
        slug:
          type: [string, 'null']
          description: |
            URL-safe identifier echoed unchanged so the voice agent
            can correlate back to /api/menu.json or the HTML page at
            /menu/{slug}. Null when the source item is unslugged.
        name:
          type: string
          description: |
            Full menu-board name with TTS punctuation stripped
            (em-dashes to commas, middle-dots to commas, trademark
            symbols removed). Read aloud as-is.
        price_spoken:
          type: string
          description: |
            Price rendered as words via dollarsToWords —
            "seventeen dollars", "sixteen dollars for the sandwich,
            twenty-two dollars for the plate", "market",
            "members only". Never carries a "$" character.
        description_spoken:
          type: string
          description: |
            Short menu-board prose with TTS-hostile punctuation
            stripped.
        category:
          type: string
          description: |
            Category display name ("Sandwiches", "Mac bowls"). Voice
            skills filter on this to answer "what sandwiches do you
            have" with a single pass.
        tag_spoken:
          type: [string, 'null']
          description: |
            "Signature", "Truck only", "Vegetarian" etc., as authored
            in src/data/menu.ts. Null when no tag.
        ingredients_spoken:
          type: string
          description: |
            Ingredient list rendered as a natural sentence built from
            the hyphenated slugs in the source item.ingredients[]
            (humanized: "pulled-pork" to "pulled pork") joined with
            an Oxford comma. Empty string when the source item carries
            no ingredients[].
        heat_spoken:
          type: [string, 'null']
          description: |
            Heat-level ladder rendered as a phrase ("Mild heat",
            "Medium heat", "Spicy", "Hot, chile-forward, sustained",
            "Extreme, challenge level"). Null when heat is 0 or unset.
        truck_only:
          type: boolean
          description: |
            True when the item is only available from the Mash
            Mechanix and Star Ranch trucks.
    VoiceFriendlyMenuResponse:
      type: object
      description: |
        Top-level body of GET /api/menu/voice-friendly.json. Mirrors
        VoiceFriendlyMenuResponse in src/lib/types/voice-menu.ts.
        Flat list of items — no nested categories. Authoring order
        matches /api/menu.json.
      required: [items, generated_at, total, base_url]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/VoiceFriendlyMenuItem'
          description: |
            Every menu item, flat list. Sandwiches first, then
            mac-bowls, plates, ribs, sides, sauces, kids, sweets.
        generated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp the response was built.
        total:
          type: integer
          minimum: 0
          description: items.length convenience field.
        base_url:
          type: string
          format: uri
          description: Absolute base URL of the site.
    MenuTodayItem:
      type: object
      description: |
        One row in MenuTodayResponse.items. Mirrors MenuTodayItem in
        src/lib/types/menu-today.ts. Every `MenuItem` with a defined
        slug across src/data/menu.ts is projected into a row.
        Tracked-in-D1 items carry a numeric `available_qty` + a
        per-location `location_slug` + the predicted sell-out
        wall-clock; always-on mainline items default to null on those
        three fields and `sold_out: false`.
      required:
        [slug, name, category, price_usd, available_qty, location_slug, sold_out, estimated_sellout_time]
      properties:
        slug:
          type: string
          description: |
            URL-safe identifier — matches MenuFeedItem.slug shape in
            /api/menu.json and the last path segment of /menu/<slug>.
        name:
          type: string
          description: Full menu-board name as printed.
        category:
          type: string
          description: |
            Slug of the parent MenuCategory ("sandwiches", "mac-bowls",
            "plates", "ribs"). Matches the categories[].slug shape
            /api/menu.json ships so consumers can cross-reference.
        price_usd:
          type: string
          description: |
            Price string preserving source shape ("$14", "market",
            "members only"). Not normalized to cents — verbatim from
            MenuItem.price.
        available_qty:
          type: [integer, 'null']
          minimum: 0
          description: |
            (total - sold) summed across every location that tracks
            the item, clamped at 0. null when the item is not tracked
            by daily_inventory (always-on mainline menu).
        location_slug:
          type: [string, 'null']
          description: |
            When the item is tracked at exactly one location, that
            location's slug ('the-cantina', 'mash-mechanix', …).
            'cantina-wide' when tracked across more than one location
            (the legacy aggregate marker from iter-15's per-truck
            split). null when not tracked.
        sold_out:
          type: boolean
          description: |
            True when available_qty != null AND available_qty <= 0.
            False when not tracked (default-available mainline menu).
            The explicit signal — absence of an inventory row is NOT
            the same as sold out.
        estimated_sellout_time:
          type: [string, 'null']
          description: |
            Wall-clock prediction ("2:45pm" America/Denver) from
            predictTodaysSelloutTime() when the kitchen has sold > 0
            today AND the item is not yet sold out. null when there
            is no signal, sold === 0, or the item is already sold
            out. Consumers display verbatim; no further parsing.
    MenuTodayResponse:
      type: object
      description: |
        Top-level body of GET /api/menu/today.json. Mirrors
        MenuTodayResponse in src/lib/types/menu-today.ts. Today's
        availability snapshot — every menu item with a slug, with a
        sold_out boolean per slug and the predicted sell-out
        wall-clock attached to tracked items.

        Edge-cached for 5 minutes — inventory moves fast.
      required: [date, items, generated_at]
      properties:
        date:
          type: string
          format: date
          description: |
            YYYY-MM-DD Denver calendar date the response was built
            for. Same string used as the daily_inventory.date key so
            consumers can cross-reference.
        items:
          type: array
          items:
            $ref: '#/components/schemas/MenuTodayItem'
          description: |
            Every MenuItem with a defined slug across src/data/menu.ts.
            Authoring order matches /api/menu.json. Items without a
            slug are skipped.
        generated_at:
          type: string
          format: date-time
          description: |
            ISO 8601 timestamp the response was built. Edge cache may
            serve this for up to 5 minutes — treat it as the build
            stamp, not a live read.
    MenuRecommendationFor:
      type: string
      enum:
        - family-of-4
        - lunch-under-15
        - date-night
        - mug-club
        - tailgate
      description: |
        Supported occasion values for the `?for=` query string of
        /api/menu/recommendations.json. Mirrors MenuRecommendationFor
        in src/lib/types/menu-recommendations.ts. Adding a value is a
        contract change — extend the union, the scorer in
        src/lib/menu-scorer.ts, and the docs in lockstep.
    MenuRecommendation:
      type: object
      description: |
        One row in MenuRecommendationsResponse.recommendations. Mirrors
        MenuRecommendation in src/lib/types/menu-recommendations.ts —
        keep both in sync when fields change. `score` is a relative
        ranking within the response, NOT comparable across occasions.
      required: [slug, name, price_usd, score, reasoning_short]
      properties:
        slug:
          type: string
          description: |
            URL-safe identifier — matches MenuItem.slug shape in
            /api/menu.json and the last path segment of /menu/<slug>.
            Items without a slug are filtered out upstream so this
            field is never null on the wire.
        name:
          type: string
          description: Full menu-board name as printed.
        price_usd:
          type: string
          description: |
            Price string verbatim from MenuItem.price ("$16", "market",
            "members only"). Currency symbol included; the field is a
            label, not a number.
        score:
          type: integer
          minimum: 0
          maximum: 100
          description: |
            0-100 integer. Normalized + rounded by the scorer in
            src/lib/menu-scorer.ts. Relative ranking within the
            occasion only — do not compare scores across occasions.
        reasoning_short:
          type: string
          description: |
            One-line hand-written explainer. Shared across every row of
            the same occasion — the row-level differentiator is the
            score, not the prose.
    MenuRecommendationsResponse:
      type: object
      description: |
        Top-level body of GET /api/menu/recommendations.json. Mirrors
        MenuRecommendationsResponse in
        src/lib/types/menu-recommendations.ts. Deterministic, no LLM
        round-trip — the scorer's heuristics are documented inline in
        src/lib/menu-scorer.ts so chef Eve can read one function and
        confirm what the site recommends. Honest weakness: MenuItem
        does not track serves_count / popularity / mug_club_favorite /
        travel_well today, so the scorer infers from price + category
        + tag and documents the fallback path per occasion.
      required: [for, recommendations, generated_at]
      properties:
        for:
          $ref: '#/components/schemas/MenuRecommendationFor'
        recommendations:
          type: array
          items:
            $ref: '#/components/schemas/MenuRecommendation'
          description: |
            Top six items scored for this occasion, sorted desc by
            `score`. Fewer than six when the menu has fewer eligible
            items for the occasion.
        generated_at:
          type: string
          format: date-time
          description: |
            ISO 8601 timestamp the response was built. Edge cache may
            serve this for up to 5 minutes — treat as build stamp, not
            a live read.
    SpecialsCurrentRecurring:
      type: object
      description: |
        One row in SpecialsCurrentResponse.recurring_specials. Mirrors
        SpecialsCurrentRecurring in src/lib/types/specials-current.ts.
        Derived from src/data/menu.ts — items whose `tag` is in
        {Saturday only, Rotating, Truck only, Mug Club} OR whose
        `truckOnly` is true. Items without a slug are filtered out
        upstream so the join into /menu/<slug> always works.
      required: [slug, name, category, price, tag, description, truck_only]
      properties:
        slug:
          type: string
          description: |
            URL-safe identifier — matches the MenuFeedItem.slug shape
            in /api/menu.json and the last path segment of
            /menu/<slug>.
        name:
          type: string
          description: Full menu-board name as printed.
        category:
          type: string
          description: |
            Slug of the MenuCategory the item lives in ("sandwiches",
            "plates", "ribs"). Matches the categories[].slug shape
            /api/menu.json ships so consumers can cross-reference.
        price:
          type: string
          description: |
            Price string preserving source shape ("$12", "$16 sandwich
            · $22 plate"). Not normalized to cents.
        tag:
          type: string
          description: |
            Special's tag as authored on the MenuItem ("Saturday only",
            "Rotating", "Truck only", "Mug Club"). Defaults to "Truck
            only" when the item is truckOnly but carries no explicit
            tag — consumers always have a non-empty label to render.
        description:
          type: string
          description: Short menu-board prose, one sentence.
        truck_only:
          type: boolean
          description: |
            True when the item is only available from the Mash
            Mechanix and Star Ranch trucks (not at The Cantina or OCC).
    SpecialsCurrentInventoryRow:
      type: object
      description: |
        One row in SpecialsCurrentResponse.todays_inventory. Mirrors
        SpecialsCurrentInventoryRow in src/lib/types/specials-current.ts.
        Adds the pre-computed `remaining` (max(total - sold, 0)) so
        the consumer doesn't re-derive on every render.
      required: [location_slug, item, total, sold, remaining, updated_at]
      properties:
        location_slug:
          type: string
          description: |
            Matches src/data/locations.ts slug (`the-cantina`,
            `mash-mechanix`, …) or the legacy marker `cantina-wide`
            for rows pre-dating iter-15's per-truck split.
        item:
          type: string
          description: |
            Menu-item slug the inventory row tracks (`brisket`,
            `ribs`, etc).
        total:
          type: integer
          minimum: 0
          description: Count the kitchen set when they opened.
        sold:
          type: integer
          minimum: 0
          description: Count sold so far today.
        remaining:
          type: integer
          minimum: 0
          description: |
            `max(total - sold, 0)`. The headless consumer reads this
            directly without re-deriving.
        updated_at:
          type: string
          format: date-time
          description: |
            ISO 8601 timestamp of the last write — the closest proxy
            for "when this row was last touched".
    SpecialsCurrentResponse:
      type: object
      description: |
        Top-level body of GET /api/specials/current.json. Mirrors
        SpecialsCurrentResponse in src/lib/types/specials-current.ts.
        Two source surfaces (recurring specials derived from menu tags;
        live per-truck inventory rows) collapsed into one payload for
        AI agents asking "what's the special today".
      required:
        [day_of_week, iso_date, recurring_specials, todays_inventory, generated_at, base_url]
      properties:
        day_of_week:
          type: string
          description: |
            Long-form weekday name in America/Denver, e.g. "Saturday".
            Drives the Saturday-only filter on recurring_specials.
          example: Saturday
        iso_date:
          type: string
          format: date
          description: |
            YYYY-MM-DD Denver calendar date the response was built
            for. Same string used as the daily_inventory.date key so
            consumers can cross-reference.
        recurring_specials:
          type: array
          items:
            $ref: '#/components/schemas/SpecialsCurrentRecurring'
          description: |
            Items derived from src/data/menu.ts tags + truckOnly flag.
            Saturday-only items only appear when day_of_week ===
            "Saturday". Empty array on slow days is valid.
        todays_inventory:
          type: array
          items:
            $ref: '#/components/schemas/SpecialsCurrentInventoryRow'
          description: |
            Every (item, location_slug) row in D1 daily_inventory for
            iso_date. Empty array on days the kitchen hasn't seeded
            inventory is valid.
        generated_at:
          type: string
          format: date-time
          description: |
            ISO 8601 timestamp the response was built. Edge cache may
            serve this for up to 10 minutes — treat it as the build
            stamp, not a live read.
        base_url:
          type: string
          format: uri
          description: |
            Absolute base URL the site is served from. Consumers that
            build /menu/<slug> deep links anchor on this.
    MenuCategoriesFeedCategory:
      type: object
      description: |
        One row in MenuCategoriesFeedResponse.categories. Mirrors
        MenuCategoriesFeedCategory in
        src/lib/types/menu-categories-feed.ts — keep both in sync when
        fields change.
      required: [slug, name, title, description, item_count]
      properties:
        slug:
          type: string
          description: |
            URL-safe identifier. Matches the section anchor on /menu and
            the slug used in /api/menu.json categories — use as the join
            key when cross-referencing /api/menu.json for item detail.
        name:
          type: string
          description: Short display name ("Sandwiches", "Mac bowls").
        title:
          type: string
          description: Long display title ("The Sandwich Section.").
        description:
          type: [string, 'null']
          description: |
            Short operator blurb (the `copy` field in src/data/menu.ts)
            that prints under the section title on /menu. Null only when
            a future category omits the field.
        item_count:
          type: integer
          minimum: 0
          description: |
            Number of items in this category at build time. Reflects
            the same items /api/menu.json ships in
            `categories[].items`.
    MenuCategoriesFeedResponse:
      type: object
      description: |
        Top-level body of GET /api/menu-categories.json. Mirrors
        MenuCategoriesFeedResponse in
        src/lib/types/menu-categories-feed.ts.
      required: [categories, generated_at, base_url]
      properties:
        categories:
          type: array
          items:
            $ref: '#/components/schemas/MenuCategoriesFeedCategory'
          description: |
            Every category on the menu board, in authoring order
            (matches /menu rendering order and /api/menu.json
            categories). Not alphabetized.
        generated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp the response was built.
        base_url:
          type: string
          format: uri
          description: |
            Absolute base URL the site is served from. Consumers that
            construct /menu#<slug> deep links anchor on this.
    MenuByCategoryFeedCategory:
      type: object
      description: |
        Identifier block for the matched category, surfaced inside
        MenuByCategoryFeedResponse. Mirrors
        MenuByCategoryFeedCategory in
        src/lib/types/menu-by-category-feed.ts — keep both in sync
        when fields change.
      required: [slug, name, title, description]
      properties:
        slug:
          type: string
          description: |
            URL-safe identifier. Matches the section anchor on /menu
            and the slug used in /api/menu.json categories — echoed so
            a client fanning out to several category feeds can pair
            responses without juggling URLs.
        name:
          type: string
          description: Short display name ("Sandwiches", "Mac bowls").
        title:
          type: string
          description: Long display title ("The Sandwich Section.").
        description:
          type: [string, 'null']
          description: |
            Short operator blurb (the `copy` field in
            src/data/menu.ts) that prints under the section title on
            /menu. Null only when a future category omits the field;
            same convention /api/menu-categories.json uses.
    MenuByCategoryFeedResponse:
      type: object
      description: |
        Top-level body of GET
        /api/menu/by-category/{category}/feed.json. Mirrors
        MenuByCategoryFeedResponse in
        src/lib/types/menu-by-category-feed.ts. Reuses the
        MenuFeedItem row shape so a consumer that already renders
        /api/menu.json gets the slice for free.
      required: [category, items, generated_at, total, base_url]
      properties:
        category:
          $ref: '#/components/schemas/MenuByCategoryFeedCategory'
        items:
          type: array
          items:
            $ref: '#/components/schemas/MenuFeedItem'
          description: |
            Every MenuFeedItem in this category, in authoring order
            (not alphabetized). Same projection shape /api/menu.json
            ships inside `categories[].items`.
        generated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp the response was built.
        total:
          type: integer
          minimum: 0
          description: items.length convenience field.
        base_url:
          type: string
          format: uri
          description: |
            Absolute base URL the site is served from. Consumers that
            join into /api/menu.json or build /menu#<slug> deep links
            anchor on this.
    MenuByAllergenItem:
      type: object
      description: |
        One row in the items[] array of MenuByAllergenResponse. Mirrors
        MenuByAllergenItem in src/lib/types/menu-by-allergen.ts — keep
        both in sync when fields change.
      required: [slug, name, category, price_usd, contains, notes]
      properties:
        slug:
          type: string
          description: |
            Stable URL-safe identifier matching MenuItem.slug. Items
            without a slug in src/data/menu.ts are skipped — no stable
            join key.
        name:
          type: string
          description: Display name verbatim from src/data/menu.ts.
        category:
          type: string
          description: |
            Display name of the parent MenuCategory ("Sandwiches",
            "Mac bowls"). Same string carried in
            /api/menu-categories.json under `name`.
        price_usd:
          type: string
          description: |
            Price string verbatim ("$16"). Currency symbol included;
            the field is a label, not a number.
        contains:
          type: boolean
          description: |
            True when the item contains (or may contain) the queried
            allergen as plated; false when the kitchen can plate it
            without the allergen. Conservative default for
            gluten-free/dairy-free: missing per-item flag → true.
            Nut/sesame/sulfite always false (kitchen-policy posture).
        notes:
          type: [string, 'null']
          description: |
            Short operator note (1-2 sentences) flagging swap
            availability, smoke-source quirks, or cross-contact
            warnings. Null when there's nothing the diner needs to
            ask about.
    MenuByAllergenResponse:
      type: object
      description: |
        Top-level body of GET
        /api/menu/by-allergen/{allergen}/feed.json. Mirrors
        MenuByAllergenResponse in src/lib/types/menu-by-allergen.ts.
        Inclusive-list posture — both safe AND unsafe items are
        present, tagged via the `contains` boolean on each row.
      required: [allergen, items, generated_at, count]
      properties:
        allergen:
          type: string
          enum:
            - gluten-free
            - dairy-free
            - nut-free
            - sesame-free
            - sulfite-free
          description: |
            Validated allergen slug echoed back so a fan-out client can
            correlate this response with the query without juggling
            URLs.
        items:
          type: array
          items:
            $ref: '#/components/schemas/MenuByAllergenItem'
          description: |
            Every menu item with a slug, in authoring order (not
            alphabetized). Inclusive list — both safe and unsafe items
            are present, tagged by `contains` per row.
        generated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp the response was built.
        count:
          type: integer
          minimum: 0
          description: items.length convenience field.
    RecipesFeedEntry:
      type: object
      description: |
        One entry in the /api/recipes.json feed. Mirrors RecipesFeedEntry
        in src/lib/types/recipes-feed.ts — keep both in sync when fields
        change.
      required:
        [slug, title, dish, season, ingredients, method, cookTime, prepTime, servings, tip, sourceBlog, publishedDate, url]
      properties:
        slug:
          type: string
          description: Kebab-case recipe slug, also the last path segment of url.
          example: slow-cooker-pulled-pork-apartment-version
        title:
          type: string
          description: Plain text. No markdown, no HTML.
        dish:
          type: string
          description: Short noun phrase for hero pills + cards (e.g. "Pulled pork").
          example: Pulled pork
        season:
          type: string
          enum: [spring, summer, fall, winter, any]
          description: Season hint for seasonal merch + cross-sell hooks.
        ingredients:
          type: array
          items:
            type: string
          description: Human-readable lines as authored, in shopping-list order.
        method:
          type: array
          items:
            type: string
          description: Numbered method steps as short paragraphs, in cook order.
        cookTime:
          type: string
          description: Human label like "8 hours" or "2-4 hours". Authored, not computed.
          example: '8 hours'
        prepTime:
          type: [string, 'null']
          description: |
            Human label like "15 min (night before)", or null when the
            recipe has no separate prep step.
        servings:
          type: string
          description: Human yield label — same string the JSON-LD Recipe.recipeYield carries.
          example: 6-8 sandwiches
        tip:
          type: string
          description: One-paragraph chef tip — the border-l ember callout on the HTML page.
        sourceBlog:
          type: [string, 'null']
          description: |
            Optional slug of the long-form blog post this recipe pairs
            with, or null when the recipe stands alone.
        publishedDate:
          type: string
          format: date
          description: ISO yyyy-mm-dd publication date.
        url:
          type: string
          format: uri
          description: Absolute https URL to the recipe page.
    RecipesFeedResponse:
      type: object
      description: |
        Top-level body of GET /api/recipes.json. Mirrors
        RecipesFeedResponse in src/lib/types/recipes-feed.ts.
      required: [recipes, featured_this_week, generated_at, total, base_url]
      properties:
        recipes:
          type: array
          items:
            $ref: '#/components/schemas/RecipesFeedEntry'
          description: Every published recipe, newest first by publishedDate.
        featured_this_week:
          type: [string, 'null']
          description: |
            Slug of the recipe rotated in as "this week's recipe", or
            null when the recipe set is empty. UTC week-of-year mod
            recipes.length — the pick changes every Monday. The full
            entry for the slug also appears in `recipes`; resolve by
            slug rather than expecting a sibling embed.
        generated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp the response was built.
        total:
          type: integer
          minimum: 0
          description: recipes.length convenience field.
        base_url:
          type: string
          format: uri
          description: Absolute base URL all entry url values are built from.
    RecipeFeedSingle:
      type: object
      description: |
        Per-recipe payload — body of
        /api/recipes/{slug}/feed.json.recipe. Mirrors RecipeFeedSingle
        in src/lib/types/recipe-feed.ts — keep both in sync when fields
        change. Carries every field the HTML page at /recipes/{slug}
        renders plus the parsed-minutes timing fields agents can use
        to set timers.
      required:
        [slug, name, intro, ingredients, steps, yield, prep_time_minutes, cook_time_minutes, total_time_minutes, prep_time_label, cook_time_label, equipment_needed, difficulty, season, source_blog, published_date, tip, url]
      properties:
        slug:
          type: string
          description: |
            Kebab-case recipe slug, also the last path segment of url.
            Echoed back so a client that fanned out to several slugs
            can pair responses without juggling URLs.
          example: slow-cooker-pulled-pork-apartment-version
        name:
          type: string
          description: Full recipe title as authored. Plain text, no markdown.
        intro:
          type: string
          description: |
            One- to two-sentence intro — same string the HTML page
            renders for cards + meta description (Recipe.description in
            src/data/recipes.ts).
        ingredients:
          type: array
          items:
            type: string
          description: Human-readable lines as authored, in shopping-list order.
        steps:
          type: array
          items:
            type: string
          description: |
            Numbered method steps, written as full short paragraphs.
            Order is the cook order (Recipe.method in
            src/data/recipes.ts).
        yield:
          type: string
          description: |
            Human yield label ("6-8 sandwiches", "1 cup rub"). Same
            string Recipe.servings carries and what JSON-LD
            Recipe.recipeYield ships.
          example: 6-8 sandwiches
        prep_time_minutes:
          type: [integer, 'null']
          minimum: 0
          description: |
            Recipe.prepTime parsed to minutes. Null when the source
            string doesn't match a known shape — the human label is
            still on prep_time_label for the consumer to render.
        cook_time_minutes:
          type: [integer, 'null']
          minimum: 0
          description: |
            Recipe.cookTime parsed to minutes. Null on parse failure.
            Range shapes pick the lower bound — agents should start the
            timer there and re-check.
        total_time_minutes:
          type: [integer, 'null']
          minimum: 0
          description: |
            prep_time_minutes + cook_time_minutes when both are
            non-null, else null. Pre-computed so the consumer doesn't
            re-derive.
        prep_time_label:
          type: [string, 'null']
          description: |
            Original Recipe.prepTime string ("15 min (night before)"),
            or null when the recipe has no separate prep step.
        cook_time_label:
          type: string
          description: |
            Original Recipe.cookTime string ("8 hours", "2-4 hours").
            Always present — every recipe carries one.
          example: '8 hours'
        equipment_needed:
          type: array
          items:
            type: string
          description: |
            Equipment labels. EMPTY in v1 — the Recipe type does not
            carry an equipment field today and the brand voice rule
            forbids inventing copy the operator did not write. Field
            is on the contract for the future when the operator
            authors it upstream.
        difficulty:
          type: string
          enum: [home-cook]
          description: |
            Skill ladder. HARDCODED to 'home-cook' in v1 — every
            published recipe is a home-cook adaptation of a pit
            technique by design. Field is on the contract so a future
            intermediate / advanced ladder doesn't break consumers.
        season:
          type: string
          enum: [spring, summer, fall, winter, any]
          description: |
            Season hint — same enum the index feed /api/recipes.json
            ships on RecipesFeedEntry.season.
        source_blog:
          type: [string, 'null']
          description: |
            Optional slug of the long-form blog post this recipe pairs
            with, or null when the recipe stands alone. Lets a consumer
            build the recipe → blog internal-link spine.
        published_date:
          type: string
          format: date
          description: |
            ISO yyyy-mm-dd publication date. Snake-case rename of
            Recipe.publishedDate for wire consistency with the rest of
            the JSON feeds in this repo.
        tip:
          type: string
          description: |
            One-paragraph chef tip — the border-l ember callout on the
            HTML page.
        url:
          type: string
          format: uri
          description: Absolute https URL to the recipe page on the live site.
    RecipeFeedSingleResponse:
      type: object
      description: |
        Top-level body of GET /api/recipes/{slug}/feed.json. Mirrors
        RecipeFeedSingleResponse in src/lib/types/recipe-feed.ts.
      required: [recipe, generated_at, base_url]
      properties:
        recipe:
          $ref: '#/components/schemas/RecipeFeedSingle'
        generated_at:
          type: string
          format: date-time
          description: |
            ISO 8601 timestamp the response was built. Edge cache may
            serve this for up to an hour — treat as build stamp, not a
            live read.
        base_url:
          type: string
          format: uri
          description: Absolute base URL all url fields are built from.
    CronLastRun:
      type: object
      description: |
        One row in SystemStatusResponse.last_cron_runs. Mirrors
        CronLastRun in src/lib/types/system-status.ts. `name` is
        the cron path segment (e.g. "mug-club-renewals",
        "d1-backup"); `last_success` is null when no success has
        been recorded yet.
      required: [name, last_success]
      properties:
        name:
          type: string
          description: Cron path segment — grep the route file for this slug.
        last_success:
          type: [string, 'null']
          format: date-time
          description: |
            ISO timestamp of the most recent successful run, or null
            when no success is on file (cron has never fired or the
            cron_runs table hasn't landed yet).
    SystemStatusUptime:
      type: object
      description: |
        Last-24h uptime aggregate. Mirrors SystemStatusUptime in
        src/lib/types/system-status.ts. Same algorithm
        /admin/site-health renders — traffic-inferred, not SLA-grade.
      required: [hours, pct]
      properties:
        hours:
          type: integer
          minimum: 0
          maximum: 24
          description: Count of distinct hours with at least one page_view in the last 24h. Capped at 24.
        pct:
          type: number
          minimum: 0
          maximum: 100
          description: hours / 24, rounded to one decimal. 0.0–100.0.
    UptimeHourBucket:
      type: object
      description: |
        One row in Uptime7dResponse.hours. Mirrors UptimeHourBucket in
        src/lib/types/uptime-7d.ts. SQLite groups by
        strftime('%Y-%m-%dT%H', captured_at) (UTC hour anchor) so
        `timestamp` is the hour bucket the snapshot landed in. No
        minutes; the cron writes one snapshot per hour.
      required: [timestamp, healthy_pct, total_checks]
      properties:
        timestamp:
          type: string
          description: |
            YYYY-MM-DDTHH UTC hour anchor. No minutes; the cron writes
            one snapshot per hour.
        healthy_pct:
          type: number
          minimum: 0
          maximum: 100
          description: |
            Average of (healthy_count * 100.0 / total_count) across the
            hour's snapshots. Zero when total_count was 0 for every
            snapshot.
        total_checks:
          type: integer
          minimum: 0
          description: |
            Count of snapshots that landed that hour. Expect 1 on a
            clean hour (the cron is hourly); lower when the cron
            skipped.
    UptimeDayBucket:
      type: object
      description: |
        One row in Uptime30dResponse.days. Mirrors UptimeDayBucket in
        src/lib/types/uptime-30d.ts. SQLite groups by date(captured_at)
        UTC so the `date` field matches the cron's captured_at
        column. `slowest_avg_ms` is nullable — a day with snapshots
        but no measurement is a data gap, not a zero.
      required: [date, healthy_pct, total_checks, slowest_avg_ms]
      properties:
        date:
          type: string
          format: date
          description: |
            YYYY-MM-DD UTC bucket date from `date(captured_at)`.
        healthy_pct:
          type: number
          minimum: 0
          maximum: 100
          description: |
            Average of (healthy_count * 100.0 / total_count) across
            the day's hourly snapshots. Zero when total_count was 0
            for every snapshot.
        total_checks:
          type: integer
          minimum: 0
          description: |
            Count of snapshots that landed that day. Expect 24 on a
            clean day; lower when the cron skipped hours.
        slowest_avg_ms:
          type: [number, 'null']
          minimum: 0
          description: |
            Average of `slowest_service_ms` across the day's
            snapshots. null when every row's slowest_service_ms was
            null — data gap, not a zero. Consumers should render
            "—" or skip the chart point.
    Uptime30dResponse:
      type: object
      description: |
        Top-level body of GET /api/health/uptime-30d.json. Mirrors
        Uptime30dResponse in src/lib/types/uptime-30d.ts. The 30-day
        rollup that pairs with the 24-hour SystemStatusUptime
        aggregate. Built for status-badge embeds and AI-ops queries
        about long-term reliability.

        D1-empty fallback: when the binding is missing the route
        ships an empty days[] and aggregate_healthy_pct = 0.
        Consumers can distinguish "no data" from "everything broken"
        via the empty days[] array.
      required: [window_days, days, aggregate_healthy_pct, generated_at]
      properties:
        window_days:
          type: integer
          const: 30
          description: |
            Window size. Always 30 in v1. Hardcoded literal so a
            consumer can assert the window without counting days[].
        days:
          type: array
          items:
            $ref: '#/components/schemas/UptimeDayBucket'
          description: |
            One UptimeDayBucket per calendar day in the last 30 days
            that landed at least one snapshot. Ordered by date
            ascending so a chart consumer walks left-to-right. Empty
            on D1-empty or a fresh database.
        aggregate_healthy_pct:
          type: number
          minimum: 0
          maximum: 100
          description: |
            Weighted average across days
            (sum(healthy_pct * total_checks) / sum(total_checks)).
            Zero when days[] is empty.
        generated_at:
          type: string
          format: date-time
          description: |
            ISO 8601 timestamp the response was built. Edge cache may
            serve this for up to an hour — treat as build stamp, not
            a live read.
    Uptime7dResponse:
      type: object
      description: |
        Top-level body of GET /api/health/uptime-7d.json. Mirrors
        Uptime7dResponse in src/lib/types/uptime-7d.ts. The 7-day
        short-window companion to Uptime30dResponse with hourly
        granularity. Designed for status-badge embeds that want
        minute-stale freshness instead of hour-stale.

        D1-empty fallback: when the binding is missing the route
        ships an empty hours[] and aggregate_healthy_pct = 0.
        Consumers can distinguish "no data" from "everything broken"
        via the empty hours[] array.
      required: [window_days, hours, aggregate_healthy_pct, generated_at]
      properties:
        window_days:
          type: integer
          const: 7
          description: |
            Window size. Always 7 in v1. Hardcoded literal so a
            consumer can assert the window without counting hours[].
        hours:
          type: array
          items:
            $ref: '#/components/schemas/UptimeHourBucket'
          description: |
            One UptimeHourBucket per UTC hour in the last 7 days that
            landed at least one snapshot. Ordered by timestamp
            ascending so a chart consumer walks left-to-right. Empty
            on D1-empty or a fresh database.
        aggregate_healthy_pct:
          type: number
          minimum: 0
          maximum: 100
          description: |
            Weighted average across hours
            (sum(healthy_pct * total_checks) / sum(total_checks)).
            Zero when hours[] is empty.
        generated_at:
          type: string
          format: date-time
          description: |
            ISO 8601 timestamp the response was built. Edge cache may
            serve this for up to 10 minutes — treat as build stamp,
            not a live read.
    SystemStatusResponse:
      type: object
      description: |
        Body of GET /api/admin/system-status.json. Mirrors
        SystemStatusResponse in src/lib/types/system-status.ts.
        `health` is null when the upstream /api/health-detailed
        probe failed or timed out. `recent_errors` is 0 in v1 — no
        Sentry token wired. `last_cron_runs` is [] in v1 — cron_runs
        table is a follow-up.
      required: [health, uptime, recent_errors, last_cron_runs, generated_at]
      properties:
        health:
          oneOf:
            - $ref: '#/components/schemas/HealthDetailedResponse'
            - type: 'null'
          description: Snapshot of /api/health-detailed, or null when the probe failed or timed out.
        uptime:
          $ref: '#/components/schemas/SystemStatusUptime'
        recent_errors:
          type: integer
          minimum: 0
          description: |
            Count of recent application errors. Hardcoded 0 in v1 —
            requires SENTRY_AUTH_TOKEN that this app doesn't ship.
            Field stays in the contract for a future Sentry-backed
            rollup.
        last_cron_runs:
          type: array
          items:
            $ref: '#/components/schemas/CronLastRun'
          description: |
            Per-cron last-success record. Empty array in v1 — the
            cron_runs table is a follow-up (admin-cron-status
            module). Field stays in the contract so consumers don't
            break when the table lands.
        generated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp the response was built.
    LaunchChecklistItemStatus:
      type: string
      enum: [green, yellow, red, unknown]
      description: |
        Per-item readiness state. Mirrors LaunchChecklistItemStatus in
        src/lib/types/launch-checklist.ts. Four-valued: `green`
        (probe clean), `yellow` (partial / stale signal), `red`
        (failing — blocks launch), `unknown` (probe couldn't run —
        honest empty state, NOT a healthy reading). Consumers MUST
        treat `unknown` as not-green.
    LaunchChecklistItem:
      type: object
      description: |
        One probe row inside a category. Mirrors LaunchChecklistItem
        in src/lib/types/launch-checklist.ts. `key` is a stable
        slug grep-able in src/lib/launch-checklist-probes.ts;
        `message` is short + actionable ('5 [REAL NAME]
        placeholders in team.ts', 'D1 binding unresolved — run
        wrangler d1 create stage-stop') — never an opaque error
        code. `doc_link` is optional — present when the fix points
        at a deep-context doc.
      required: [key, name, status, latency_ms, message]
      properties:
        key:
          type: string
          description: Stable probe slug. Grep src/lib/launch-checklist-probes.ts for this string.
          example: og-blog-rest-room
        name:
          type: string
          description: Human label the HTML page prints.
          example: OG asset — the-rest-room-protocol
        status:
          $ref: '#/components/schemas/LaunchChecklistItemStatus'
        latency_ms:
          type: integer
          minimum: 0
          description: Wall-clock for the probe. Useful for spotting a probe that quietly turned slow.
        message:
          type: string
          description: |
            Short + actionable string. '5 [REAL NAME] placeholders in
            team.ts', 'D1 binding unresolved — run wrangler d1 create
            stage-stop', 'OG asset missing: public/og/blog/the-rest-
            room-protocol.png — run npm run og:generate'. Never an
            opaque error code.
        doc_link:
          type: string
          description: |
            Optional path to a deep-context doc. Present when the fix
            isn't obvious from `message` alone.
    LaunchChecklistCategory:
      type: object
      description: |
        Grouping of related probes ('Content', 'Infrastructure',
        'SEO', 'Observability'). Mirrors LaunchChecklistCategory in
        src/lib/types/launch-checklist.ts. `name` is the H2 the
        HTML page prints; the JSON route returns the same string so
        a non-browser consumer can render the same headings.
      required: [name, items]
      properties:
        name:
          type: string
          description: Category heading.
          example: Infrastructure
        items:
          type: array
          items:
            $ref: '#/components/schemas/LaunchChecklistItem'
    LaunchChecklistResponse:
      type: object
      description: |
        Body of GET /api/admin/launch-checklist.json. Mirrors
        LaunchChecklistResponse in
        src/lib/types/launch-checklist.ts.

        `overall_progress_pct` is green-items / total-items rounded
        to one decimal — same algorithm the HTML page's progress bar
        uses. Yellow does NOT count toward green; a partial signal is
        not a finished one. `unknown` items are counted in the
        denominator — we don't pretend a probe we couldn't run isn't
        on the checklist.

        Admin-cookie gated; documented in this spec for in-repo
        discoverability, NOT as a public contract.
      required: [categories, overall_progress_pct, generated_at]
      properties:
        categories:
          type: array
          items:
            $ref: '#/components/schemas/LaunchChecklistCategory'
          description: Categorised probe set. Empty array is valid (no probes seeded).
        overall_progress_pct:
          type: number
          minimum: 0
          maximum: 100
          description: green-items / total-items, rounded to one decimal. 0.0–100.0.
        generated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp the response was built.
    IterQualityState:
      type: object
      description: |
        Quality state captured at the moment an iteration closed.
        Mirrors IterQualityState in src/lib/types/iter-history.ts.
        `tsc` is a two-value enum — every shipped iter is one of
        them. `lint_errors` and `test_count` are integer counts.
      required: [tsc, lint_errors, test_count]
      properties:
        tsc:
          type: string
          enum: [clean, errors]
          description: |
            'clean' when `tsc --noEmit` exited 0 at iter close;
            'errors' when it didn't. Closed enum — partial states
            ('warnings', 'pending') are intentionally not represented.
        lint_errors:
          type: integer
          minimum: 0
          description: |
            Count of `eslint .` errors at iter close. Zero on a
            clean iter; non-zero rows are kept on the ledger so the
            trend stays honest.
        test_count:
          type: integer
          minimum: 0
          description: |
            Total `vitest` test count at iter close. Grows
            monotonically across iters as the suite expands.
    IterHistoryEntry:
      type: object
      description: |
        One row in IterHistoryResponse.iterations. Mirrors
        IterHistoryEntry in src/lib/types/iter-history.ts — keep both
        in sync when fields change. `iter` is the numeric identifier
        (41, 42, …) the workflow journal stamps on each cluster.
      required:
        [iter, completed_at, module_count, files_changed_approx, key_landings, quality_state]
      properties:
        iter:
          type: integer
          minimum: 1
          description: |
            Numeric iteration identifier. Same number the workflow
            journal stamps on each cluster (41, 42, …).
          example: 48
        completed_at:
          type: string
          format: date
          description: |
            YYYY-MM-DD Denver calendar date the iter wrapped. Not an
            ISO timestamp — the per-iter granularity is the day, not
            the second.
          example: "2026-06-15"
        module_count:
          type: integer
          minimum: 0
          description: |
            Count of modules that landed inside the iter (the
            workflow journal's module-list length).
        files_changed_approx:
          type: integer
          minimum: 0
          description: |
            Operator's hand-rolled approximate file count. NOT a
            precise `git diff --stat` count — the audit-trail value
            is the order-of-magnitude shape.
        key_landings:
          type: array
          items:
            type: string
          description: |
            Short hand-authored list of the most notable surfaces the
            iter shipped — a route URL, a type name, a doc path.
            Renders verbatim. Authoring order, not alphabetized.
        quality_state:
          $ref: '#/components/schemas/IterQualityState'
    IterHistoryResponse:
      type: object
      description: |
        Top-level body of GET /api/admin/iter-history.json. Mirrors
        IterHistoryResponse in src/lib/types/iter-history.ts. Admin-
        cookie gated; advertised in this spec because the wire shape
        is a stable contract for future external tooling and the
        audit trail is intentionally public-ish.

        `iterations` is sorted ASCENDING by iter (oldest first).
        `current_iter` is the in-flight iter — a hand-edited
        constant in the route handler, NOT derived from
        `max(iter)+1`. `total_tests_now` is the test_count from
        the latest closed iter — convenience for a consumer that
        wants the headline number without walking the array.
      required: [iterations, current_iter, generated_at, total_tests_now]
      properties:
        iterations:
          type: array
          items:
            $ref: '#/components/schemas/IterHistoryEntry'
          description: |
            Every closed iter, sorted ascending by `iter`. The
            audit trail reads chronologically. A consumer rendering
            "current and the three before" pulls the tail of this
            array.
        current_iter:
          type: integer
          minimum: 1
          description: |
            In-flight iter the next workflow run will close. Hand-
            edited constant in the route handler, NOT derived from
            `max(iter)+1` — see docs/API.md for the operator
            runbook.
          example: 49
        generated_at:
          type: string
          format: date-time
          description: |
            ISO 8601 timestamp the response was built. Edge cache may
            serve this for up to an hour — treat as build stamp, not
            a live read.
        total_tests_now:
          type: integer
          minimum: 0
          description: |
            test_count from the latest closed iter (the tail of
            `iterations`). Zero when the iter array is empty.
    OpenApiCoverageRoute:
      type: object
      description: |
        One row in OpenApiCoverageResponse.routes. Mirrors
        OpenApiCoverageRoute in src/lib/types/openapi-coverage.ts.
        Records the (path, method) pair plus the 200 JSON response's
        bare schema name and the drift-test coverage signal. The
        `schema_ref` is the last segment after
        `#/components/schemas/` (e.g. "MenuFeedResponse"); null
        when the route has no JSON 200 response (Twilio webhook,
        Server Action redirect, text-only payload).
        `has_drift_test` is true when `schema_ref` is non-null
        AND that name appears in TRACKED_SCHEMAS in
        src/lib/openapi-coverage-tracked-schemas.ts.
      required: [path, method, schema_ref, has_drift_test, drift_test_name]
      properties:
        path:
          type: string
          description: |
            Site-relative pathname as the spec declares it
            (`/api/menu.json`,
            `/api/blog/by-tag/{tag}.json`). Path-params keep their
            curly-brace shape so the row compares equal to the
            spec.paths key verbatim.
          example: /api/menu.json
        method:
          type: string
          enum: [get, post, put, delete, patch]
          description: |
            Lowercase HTTP method as declared in the spec. Other
            methods (head, options, trace) are skipped — the spec
            never declares them and surfacing them would dilute the
            coverage signal.
          example: get
        schema_ref:
          type: [string, 'null']
          description: |
            Bare schema name from the 200 JSON response's
            `$ref` — the last segment after
            `#/components/schemas/`. Null when the route has no
            JSON 200 response (Twilio TwiML, Server Action 303
            redirect, ack-only forms).
          example: MenuFeedResponse
        has_drift_test:
          type: boolean
          description: |
            True when `schema_ref` is non-null AND that name
            appears in TRACKED_SCHEMAS in
            src/lib/openapi-coverage-tracked-schemas.ts (parity-
            asserted equal to
            `Object.keys(SCHEMA_EXPECTATIONS)` in
            tests/openapi-drift-all.test.ts).
        drift_test_name:
          type: [string, 'null']
          description: |
            `'openapi-drift-all.test.ts'` when has_drift_test is
            true; null otherwise. One file in v1; the field stays on
            the contract so a future per-schema split lands without
            a wire-shape change.
          example: openapi-drift-all.test.ts
    OpenApiCoverageResponse:
      type: object
      description: |
        Body of GET /api/admin/openapi-coverage.json. Mirrors
        OpenApiCoverageResponse in src/lib/types/openapi-coverage.ts.
        Admin-cookie gated. Built for the launch-checklist
        completeness audit — the operator's answer to "which
        endpoints aren't drift-protected?" without grepping the test
        file by hand.

        `coverage_pct` is covered_routes / total_routes rounded to
        one decimal — same rounding convention
        LaunchChecklistResponse.overall_progress_pct uses. Zero when
        total_routes is 0 (avoids NaN on the wire).
      required:
        [routes, total_routes, covered_routes, coverage_pct, generated_at]
      properties:
        routes:
          type: array
          items:
            $ref: '#/components/schemas/OpenApiCoverageRoute'
          description: |
            Every (path, method) pair the spec declares, in
            authoring (spec-key) order — NOT alphabetized. A
            consumer wanting an alphabetized view sorts client-side.
        total_routes:
          type: integer
          minimum: 0
          description: routes.length convenience field.
        covered_routes:
          type: integer
          minimum: 0
          description: |
            `routes.filter(r => r.has_drift_test).length` — the
            headline number a launch-checklist consumer reads first.
        coverage_pct:
          type: number
          minimum: 0
          maximum: 100
          description: |
            `covered_routes / total_routes * 100`, rounded to one
            decimal. Zero when total_routes is 0 to avoid NaN on the
            wire.
        generated_at:
          type: string
          format: date-time
          description: |
            ISO 8601 timestamp the response body was built.
            `Cache-Control: no-store` on the route so this stamp
            is the live read, not an edge cache build stamp.
    CompetitorsSummarySerpRanks:
      type: object
      description: |
        Structured map of tracked SERP positions for one competitor.
        Mirrors CompetitorsSummarySerpRanks in
        src/lib/types/competitors-summary.ts. Both keys are OPTIONAL —
        a competitor without a measurement returns the key as missing,
        not as null. Consumers MUST handle the missing-key shape.
      properties:
        brisket:
          type: integer
          minimum: 1
          description: |
            "brisket colorado springs" SERP position. 1-100. Omitted
            when the competitor has not been measured.
        bbq_co_springs:
          type: integer
          minimum: 1
          description: |
            "bbq colorado springs" SERP position. 1-100. Omitted when
            the competitor has not been measured.
    CompetitorsSummaryEntry:
      type: object
      description: |
        One row in the /api/competitors-summary.json feed. Mirrors
        CompetitorsSummaryEntry in
        src/lib/types/competitors-summary.ts — keep both in sync when
        fields change. `sentiment_score` is the operator-graded 1-5
        ladder, NOT derived from review stars (1 "they're hurting us",
        5 "no overlap"). `notes_excerpt` is the first ~200 chars of
        the source notes column, truncated at a word boundary; full
        prose lives in the /admin/competitor-tracker UI.
      required:
        [name, url, type, location, sentiment_score, last_reviewed_at, serp_ranks, notes_excerpt]
      properties:
        name:
          type: string
          description: Operator display name as printed in the tracker UI.
          example: Bird Dog BBQ
        url:
          type: string
          description: |
            Absolute https URL to the competitor's website. Empty string
            when the competitor has no public site.
        type:
          type: string
          description: |
            Short kind label ("brick-and-mortar", "trailer",
            "brewery-partner", "ghost kitchen"). Authored on the source
            row — not enumerated, so a new format doesn't break the
            wire.
        location:
          type: string
          description: |
            Short locator string ("Colorado Springs · 2nd location",
            "Old Colorado City"). Authored, NOT a postal field.
        sentiment_score:
          type: [integer, 'null']
          minimum: 1
          maximum: 5
          description: |
            Operator-graded 1-5 ladder, or null when ungraded. 1
            "they're hurting us", 5 "no overlap". NOT a star-count from
            customer reviews.
        last_reviewed_at:
          type: [string, 'null']
          format: date-time
          description: |
            ISO 8601 timestamp of the most recent operator review (the
            source row's `updated_at`), or null when never touched.
        serp_ranks:
          $ref: '#/components/schemas/CompetitorsSummarySerpRanks'
        notes_excerpt:
          type: string
          description: |
            First ~200 chars of the source notes, truncated at a word
            boundary. Empty string when the source row carries no
            notes. Full prose lives in /admin/competitor-tracker.
    CompetitorsSummaryResponse:
      type: object
      description: |
        Body of GET /api/competitors-summary.json. Mirrors
        CompetitorsSummaryResponse in
        src/lib/types/competitors-summary.ts. Empty `competitors` is
        valid — the operator hasn't seeded the table yet, or the D1
        binding is missing in local dev. Admin-cookie gated; documented
        in this spec for in-repo discoverability, NOT as a public
        contract.
      required: [competitors, generated_at, count]
      properties:
        competitors:
          type: array
          items:
            $ref: '#/components/schemas/CompetitorsSummaryEntry'
          description: |
            Every tracked competitor in last-reviewed-newest-first
            order, NULL last_reviewed_at sorted to the bottom. NOT
            alphabetized.
        generated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp the response was built.
        count:
          type: integer
          minimum: 0
          description: competitors.length convenience field.
    LocationHoursResponse:
      type: object
      description: |
        Top-level body of GET /api/locations/{slug}/hours.json. Mirrors
        LocationHoursResponse in src/lib/types/location-hours.ts — keep
        both in sync when fields change.
      required:
        [slug, name, hours_today, is_open_now, next_open, timezone, generated_at]
      properties:
        slug:
          type: string
          description: |
            URL-safe location identifier (`the-cantina`,
            `mash-mechanix`, etc.), echoed back so a client fanning out
            to several locations can pair responses without juggling URLs.
        name:
          type: string
          description: Operator display name as it prints on the location page.
          example: The Cantina
        hours_today:
          type: array
          items:
            type: string
            example: '11:00 AM – 8:00 PM'
          description: |
            Human strings for today's open windows. Empty array when the
            location is closed all day per the schedule. Format is fixed
            so consumers can string-match without parsing — not localized.
        is_open_now:
          type: boolean
          description: |
            True when the current America/Denver wall clock falls inside
            any of today's spec windows. Rule is [opens, closes) — the
            minute the schedule says `closes: '20:00'`, this flips false.
        next_open:
          type: [string, 'null']
          format: date-time
          description: |
            ISO 8601 timestamp (America/Denver) of the next spec window's
            opens transition, or null when the location is currently
            open. Looks forward up to seven days; null also when the
            location declares no open windows at all (not expected in v1).
        timezone:
          type: string
          enum: ['America/Denver']
          description: |
            Always `America/Denver` in v1 — every location is in
            Colorado Springs. Field is present so a future per-location
            zone doesn't break the contract.
        generated_at:
          type: string
          format: date-time
          description: |
            ISO 8601 timestamp the response was built. Edge cache may
            serve this for up to 10 minutes — treat it as the build
            stamp, not a live read.
    ClosureModifiedHours:
      type: object
      description: |
        Modified-hours block for closure entries where `closed:
        false` — the stops are open, but on a non-standard schedule.
        Times are HH:MM 24-hour America/Denver wall clock. Mirrors
        ClosureModifiedHours in src/lib/types/holidays-closures.ts.
      required: [open, close]
      properties:
        open:
          type: string
          description: HH:MM 24-hour America/Denver wall clock.
          example: "11:00"
        close:
          type: string
          description: HH:MM 24-hour America/Denver wall clock.
          example: "17:00"
    ClosureEntry:
      type: object
      description: |
        One row in the /api/holidays-and-closures.json feed. Mirrors
        ClosureEntry in src/lib/types/holidays-closures.ts — keep
        both in sync when fields change. `modified_hours` is
        present only when `closed: false`; `locations` is either
        the literal string `'all'` (every stop affected) or an
        array of location slugs (specific stops affected, others
        run normal).
      required: [date, name, type, closed, locations, message]
      properties:
        date:
          type: string
          description: |
            ISO yyyy-mm-dd date of the closure. Build-time guard in
            src/data/closures.ts asserts the format.
          example: 2026-12-25
        name:
          type: string
          description: |
            Short operator label ("Christmas Day", "Mash Mechanix
            smoker swap"). Renders in the closure banner.
          example: Christmas Day
        type:
          type: string
          enum: [holiday, private-event, maintenance]
          description: |
            Three-value enum. `holiday` = calendar holiday the
            brand observes. `private-event` = operator-booked
            buyout (wedding catering, corporate). `maintenance` =
            equipment swap, deep-clean day, smoker rebuild.
        closed:
          type: boolean
          description: |
            true = fully closed for the scope. false = open with
            `modified_hours` set.
        modified_hours:
          $ref: '#/components/schemas/ClosureModifiedHours'
        locations:
          oneOf:
            - type: string
              enum: [all]
            - type: array
              items:
                type: string
                enum:
                  - the-cantina
                  - mash-mechanix
                  - old-colorado-city
                  - urban-animal-star-ranch
          description: |
            `'all'` when every stop is affected; an array of
            location slugs when a subset is offline. The unaffected
            stops run normal hours.
        message:
          type: string
          description: |
            Operator-facing banner copy. Chef-direct, short
            sentences, declarative. Renders verbatim on the closure
            banner.
          example: "Closed Christmas Day. See you 12/26 at 11."
    HolidaysClosuresResponse:
      type: object
      description: |
        Top-level body of GET /api/holidays-and-closures.json.
        Mirrors HolidaysClosuresResponse in
        src/lib/types/holidays-closures.ts. `upcoming` is the next
        90 days from `generated_at` (asc); `past_recent` is the
        last 30 days (desc) for AI agents reasoning about historical
        closures.
      required: [upcoming, past_recent, generated_at]
      properties:
        upcoming:
          type: array
          items:
            $ref: '#/components/schemas/ClosureEntry'
          description: |
            Every closure in the next 90 days from `generated_at`,
            sorted by `date` ascending so the banner renders the
            nearest entry first.
        past_recent:
          type: array
          items:
            $ref: '#/components/schemas/ClosureEntry'
          description: |
            Every closure in the last 30 days from `generated_at`,
            sorted by `date` descending. For AI agents reasoning
            about historical closures — not an archive.
        generated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp the response was built.
    LocationsFeedEntry:
      type: object
      description: |
        One entry in the /api/locations.json feed. Mirrors
        LocationsFeedEntry in src/lib/types/locations-feed.ts — keep
        both in sync when fields change. `brewery_partner` is
        optional and omitted from the JSON body when the source
        Location does not carry it.
      required:
        [slug, name, shortName, kind, format, neighborhood, city, state, address, zip, phone, geo, hours_week, hours_today, is_open_today, tagline, about, amenities, is_truck, url]
      properties:
        slug:
          type: string
          description: |
            Kebab-case location identifier, also the last path segment
            of `url`. Matches the slug used by
            /api/locations/{slug}/hours.json and the `location`-typed
            entries in /api/sitemap.json.
          example: the-cantina
        name:
          type: string
          description: Operator display name as it prints on the location page.
          example: The Cantina
        shortName:
          type: string
          description: Short display name for cards + chips.
        kind:
          type: string
          enum: [flagship, brewery-partner]
          description: |
            `flagship` is the Cantina; `brewery-partner` are the
            trailers / cart parked at a partner brewery taproom.
        format:
          type: string
          enum: [brick-and-mortar, trailer, food-cart]
          description: |
            Physical footprint. `is_truck` is derived from this field
            (trailer + food-cart === true) for consumers that only need
            the boolean.
        neighborhood:
          type: string
          description: Short locator string ("Downtown", "Westside"). NOT a postal field.
        city:
          type: string
          example: Colorado Springs
        state:
          type: string
          minLength: 2
          maxLength: 2
          example: CO
        address:
          type: string
          description: Street address as printed. Free-text — not normalized.
        zip:
          type: string
          description: Five-digit postal ZIP.
        phone:
          type: string
          description: |
            Display phone string. Same string the location page renders.
            Already public on the HTML page — this is not a new PII
            surface.
        geo:
          type: object
          required: [lat, lng]
          description: |
            Decimal-degree latitude + longitude. Already public on the
            HTML page (used by the directions link).
          properties:
            lat:
              type: number
            lng:
              type: number
        hours_week:
          type: string
          description: |
            Full-week human label ("Wed–Sun · 11a – 8p · Closed Mon/Tue").
            Authored, not computed.
        hours_today:
          type: string
          description: |
            Short human label ("11a – 8p", "until 8p"). Authored field
            on the Location source, NOT the live spec-window
            computation — for that, call
            /api/locations/{slug}/hours.json.
        is_open_today:
          type: boolean
          description: |
            Operator-asserted today-is-an-open-day flag from the
            Location source. NOT a live wall-clock check — call
            /api/locations/{slug}/hours.json for that.
        tagline:
          type: string
          description: One-sentence operator description.
        about:
          type: string
          description: Short prose paragraph — same string the HTML hero block renders.
        amenities:
          type: array
          items:
            type: string
          description: |
            Short labels ("Sit-down", "Full bar", "Dog-friendly patio").
            Authoring order, not alphabetized.
        brewery_partner:
          type: object
          required: [name, url]
          description: |
            Present only when the source Location carries a brewery
            partner block. `featuredBeers` live on the HTML page only
            — this feed carries the partner name + URL.
          properties:
            name:
              type: string
            url:
              type: string
              format: uri
        is_truck:
          type: boolean
          description: |
            True when `format !== 'brick-and-mortar'`. Cheap boolean
            for consumers that don't want to switch on the format enum.
        url:
          type: string
          format: uri
          description: Absolute https URL to the location detail page.
    LocationsFeedResponse:
      type: object
      description: |
        Top-level body of GET /api/locations.json. Mirrors
        LocationsFeedResponse in src/lib/types/locations-feed.ts.
      required: [locations, generated_at, total, base_url]
      properties:
        locations:
          type: array
          items:
            $ref: '#/components/schemas/LocationsFeedEntry'
          description: |
            Every location in authoring order from
            src/data/locations.ts (flagship first, then brewery
            partners). NOT alphabetized.
        generated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp the response was built.
        total:
          type: integer
          minimum: 0
          description: locations.length convenience field.
        base_url:
          type: string
          format: uri
          description: Absolute base URL all entry url values are built from.
    JobsFeedBaseSalary:
      type: object
      description: |
        Structured min/max + unit projection of Job.payRange. Mirrors
        JobsFeedBaseSalary in src/lib/types/jobs-feed.ts. Single-point
        rates set min_value === max_value; parse failure falls back to
        `0/0/HOUR` — consumers treat 0/0 as "not published" rather
        than literally zero.
      required: [currency, min_value, max_value, unit_text]
      properties:
        currency:
          type: string
          enum: [USD]
          description: ISO 4217 currency code. Always USD in v1.
        min_value:
          type: number
          minimum: 0
          description: |
            Lower bound of the authored pay range. Whole dollars
            (hourly) or whole dollars per year (salaried).
        max_value:
          type: number
          minimum: 0
          description: |
            Upper bound. Equals `min_value` when the source row
            authored a single-point rate.
        unit_text:
          type: string
          enum: [HOUR, YEAR]
          description: |
            Schema.org `unitText` — HOUR for hourly roles, YEAR for
            salaried.
    JobsFeedHiringOrganization:
      type: object
      description: |
        Schema.org Organization projection on `hiring_organization`.
        Mirrors JobsFeedHiringOrganization in src/lib/types/jobs-feed.ts.
      required: [name, url, logo]
      properties:
        name:
          type: string
          description: Operator display name. Always brand.name.
          example: Stage Stop BBQ
        url:
          type: string
          format: uri
          description: Absolute https URL to the org homepage. Always brand.url.
        logo:
          type: string
          format: uri
          description: |
            Absolute https URL to the brand OG card. Stable URL even if
            the image is regenerated.
    JobsFeedJobLocation:
      type: object
      description: |
        Schema.org Place + PostalAddress projection on `job_location`.
        Mirrors JobsFeedJobLocation in src/lib/types/jobs-feed.ts. Flat
        snake_case keys for wire ergonomics; consumers building strict
        schema.org JSON-LD remap to camelCase locally.
      required:
        [address_locality, address_region, postal_code, street_address, country]
      properties:
        address_locality:
          type: string
          description: City. Always Colorado Springs in v1.
          example: Colorado Springs
        address_region:
          type: string
          minLength: 2
          maxLength: 2
          description: Two-letter US state code.
          example: CO
        postal_code:
          type: string
          description: Five-digit US ZIP.
          example: "80906"
        street_address:
          type: string
          description: |
            Street line as printed on the location page. For roles
            spanning every stop (`location_slug === 'all'`), this is
            brand.hq.street (the Cantina address) and `location_name`
            on the parent row carries the "Multiple locations" label.
        country:
          type: string
          enum: [US]
          description: ISO 3166-1 alpha-2 country code. Always US in v1.
    JobsFeedPosition:
      type: object
      description: |
        One row in JobsFeedResponse.positions. Mirrors JobsFeedPosition
        in src/lib/types/jobs-feed.ts — keep both in sync when fields
        change. Schema.org JobPosting projection: every field Google
        for Jobs requires is on the wire.
      required:
        [slug, title, department, location_slug, location_name, employment_type, posted_at, valid_through, description, responsibilities, qualifications, base_salary, apply_email, apply_url, hiring_organization, job_location]
      properties:
        slug:
          type: string
          description: |
            Kebab-case role identifier. Matches Job.slug in
            src/data/jobs.ts and the `#<slug>` deep anchor on
            /careers.
          example: pitmaster-cantina
        title:
          type: string
          description: Full role title as printed. Plain text.
          example: "Pitmaster · The Cantina"
        department:
          type: string
          enum: [pit, service, truck, training]
          description: |
            Coarse bucket the role rolls up under. Hand-rolled from
            roleShort + locationSlug; consumers filtering on
            department get a small enum without learning every
            authored role name.
        location_slug:
          type: string
          description: |
            Job.locationSlug verbatim — the slug of the location in
            src/data/locations.ts, or the literal `'all'` for roles
            spanning every stop (catering coordinator).
          example: the-cantina
        location_name:
          type: string
          description: |
            Operator display name. For `location_slug: 'all'` this
            is "Multiple locations · Colorado Springs, CO".
        employment_type:
          type: string
          enum: [FULL_TIME, PART_TIME, TEMPORARY]
          description: |
            Schema.org `employmentType` vocabulary. Mapped from
            Job.type: full-time/salaried/pt-ft → FULL_TIME,
            part-time → PART_TIME. TEMPORARY is reserved for seasonal
            roles (none open in v1; contract field for the future).
        posted_at:
          type: string
          format: date-time
          description: |
            ISO 8601 timestamp the response was built. Schema.org
            `datePosted` equivalent. Edge cache may serve this for
            up to an hour — treat as a freshness stamp, not an exact
            authored-at date.
        valid_through:
          type: string
          format: date-time
          description: |
            ISO 8601 timestamp the posting expires. Schema.org
            `validThrough` equivalent. v1 ships posted_at + 30 days
            — the recruiter re-confirms availability monthly so
            Google for Jobs does not surface a stale role
            indefinitely.
        description:
          type: string
          description: |
            Short prose summary, same string Job.description carries
            on the source row. Schema.org JobPosting canonical key —
            third-party board ingest reads this field.
        description_summary:
          type: string
          deprecated: true
          description: |
            DEPRECATED — read `description` instead. Removed in v2 of
            the feed. Carries the same string as `description` on
            every v1.x body so callers pinned to the old name keep
            working through the deprecation window.
        responsibilities:
          type: array
          items:
            type: string
          description: |
            Bullet-list responsibilities. v1 always ships [] — the
            Job type does not carry a structured responsibilities
            field today. Field stays on the contract so a future
            operator-authored list lands without a wire-shape change.
        qualifications:
          type: array
          items:
            type: string
          description: |
            Bullet-list qualifications. v1 always ships [] for the
            same reason as `responsibilities`.
        base_salary:
          $ref: '#/components/schemas/JobsFeedBaseSalary'
        apply_email:
          type: string
          format: email
          description: |
            Operator-facing application inbox. Public per the
            /careers convention — already on the rendered page.
        apply_url:
          type: string
          format: uri
          description: |
            Canonical absolute URL the third-party board links
            applicants to. Deep-anchored to /careers#<slug> — the
            canonical careers surface today.
        hiring_organization:
          $ref: '#/components/schemas/JobsFeedHiringOrganization'
        job_location:
          $ref: '#/components/schemas/JobsFeedJobLocation'
    JobsFeedResponse:
      type: object
      description: |
        Top-level body of GET /api/jobs.json. Mirrors JobsFeedResponse
        in src/lib/types/jobs-feed.ts. Open-positions feed for Google
        for Jobs + third-party boards + AI agents — schema.org
        JobPosting projection of every currently-hiring role.
      required: [positions, count, generated_at, schema_version]
      properties:
        positions:
          type: array
          items:
            $ref: '#/components/schemas/JobsFeedPosition'
          description: |
            Every currently-open role in source authoring order
            (operator-ranked for the recruiter funnel — NOT
            alphabetized). v1 emits every row in src/data/jobs.ts
            because Job has no is_active flag today.
        count:
          type: integer
          minimum: 0
          description: positions.length convenience field.
        generated_at:
          type: string
          format: date-time
          description: |
            ISO 8601 timestamp the response body was built. Edge cache
            may serve this for up to an hour — treat as a freshness
            stamp, not a live read.
        schema_version:
          type: string
          description: |
            Wire-shape version tag. v1.1 added the schema.org-canonical
            `description` field on every position alongside the
            deprecated `description_summary` alias. Consumers pinned
            to a version should refuse bodies whose major version they
            don't recognize. See docs/JOBS_FEED.md.
          example: v1.1
    AckResponse:
      type: object
      required: [ok]
      properties:
        ok:
          type: boolean
    ErrorResponse:
      type: object
      required: [ok, error]
      properties:
        ok:
          type: boolean
          enum: [false]
        error:
          type: string
          description: Short machine-readable error slug.
        message:
          type: string
          description: Human-readable detail. Safe to display.
    SitemapUrlEntry:
      type: object
      required: [path, type, last_modified, priority, change_frequency, language, hreflang_alternates]
      properties:
        path:
          type: string
          description: Site-root-relative pathname (e.g. /menu/brisket).
        type:
          type: string
          enum:
            - page
            - menu-dish
            - location
            - brewery
            - blog
            - event
            - ingredient
            - heat-level
            - recipe
            - specials-year
            - catering-occasion
            - staff
          description: |
            URL classification derived from the pathname shape so AI agents
            can group results without re-deriving the taxonomy.
        last_modified:
          type: [string, 'null']
          format: date-time
          description: ISO-8601 timestamp of the last meaningful change, or null when unknown.
        priority:
          type: [number, 'null']
          minimum: 0
          maximum: 1
          description: Sitemap priority hint (0.0–1.0), or null when unset.
        change_frequency:
          type: [string, 'null']
          enum: [always, hourly, daily, weekly, monthly, yearly, never, null]
          description: Suggested re-crawl cadence, or null when unset.
        language:
          type: string
          enum: [en]
          description: Primary language of the resource at `path`.
        hreflang_alternates:
          type: [object, 'null']
          additionalProperties:
            type: string
          description: |
            BCP-47-keyed map of alternate-language absolute URLs for this
            resource (e.g. {"es": "https://…/es/menu"}). Null when no
            alternates are declared.
    SitemapJsonResponse:
      type: object
      required: [urls, generated_at, base_url]
      properties:
        urls:
          type: array
          items:
            $ref: '#/components/schemas/SitemapUrlEntry'
        generated_at:
          type: string
          format: date-time
          description: ISO-8601 timestamp the response was assembled.
        base_url:
          type: string
          description: Absolute base URL all `path` values are relative to.
    BlogByAuthorFeedResponse:
      type: object
      description: |
        Top-level body of GET /api/blog/by-author/{author}/feed.json.
        Mirrors BlogByAuthorFeedResponse in
        src/lib/types/blog-by-author-feed.ts — keep both in sync when
        fields change. Reuses the BlogFeedEntry row shape so a consumer
        that already renders /api/blog/all.json gets the author archive
        for free.
      required: [author, author_slug, posts, generated_at, count]
      properties:
        author:
          type: string
          description: |
            Display name as authored in src/data/blog-posts.ts
            ("Chef Eve Natasha"). NOT the kebab-case slug — the slug
            is in `author_slug`.
          example: Chef Eve Natasha
        author_slug:
          type: string
          description: |
            Kebab-case URL slug that matched. Echoed back so a client
            fanning out to several author feeds can pair responses
            without juggling URLs. Derived from the display name via
            `authorToSlug` in src/lib/types/blog-by-author-feed.ts.
          example: chef-eve-natasha
        posts:
          type: array
          items:
            $ref: '#/components/schemas/BlogFeedEntry'
          description: |
            Every post by this author. No pagination — the per-author
            count is bounded by the full journal size. Newest first by
            date, ties broken by slug (same rule as /api/blog/all.json).
        generated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp the response was built.
        count:
          type: integer
          minimum: 0
          description: posts.length convenience field.
    BlogByDateFeedResponse:
      type: object
      description: |
        Top-level body of GET /api/blog/by-date/{year}/{month}/feed.json.
        Mirrors BlogByDateFeedResponse in
        src/lib/types/blog-by-date-feed.ts — keep both in sync when
        fields change. Reuses the BlogFeedEntry row shape so a consumer
        that already renders /api/blog/all.json gets the month archive
        for free.
      required: [year, month, posts, generated_at, count]
      properties:
        year:
          type: integer
          description: 4-digit calendar year the posts were published in.
          example: 2026
        month:
          type: integer
          minimum: 1
          maximum: 12
          description: |
            Calendar month 1-12 (NO leading zero — matches the URL
            shape).
          example: 6
        posts:
          type: array
          items:
            $ref: '#/components/schemas/BlogFeedEntry'
          description: |
            Every post published in this (year, month) tuple. No
            pagination — the per-month count is small. Newest first by
            date, ties broken by slug (same rule as /api/blog/all.json).
        generated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp the response was built.
        count:
          type: integer
          minimum: 0
          description: posts.length convenience field.
    BlogByTagFeedResponse:
      type: object
      description: |
        Top-level body of GET /api/blog/by-tag/{tag}.json. Mirrors
        BlogByTagFeedResponse in src/lib/types/blog-by-tag-feed.ts —
        keep both in sync when fields change. Reuses the BlogFeedEntry
        row shape so a consumer that already renders /api/blog/all.json
        gets the tag archive for free.
      required: [tag, tag_slug, posts, generated_at, total]
      properties:
        tag:
          type: string
          description: |
            Canonical display label as authored in
            src/data/blog-posts.ts ("Method", "Mug Club"). NOT the
            kebab-case slug — the slug is in `tag_slug`.
          example: Method
        tag_slug:
          type: string
          description: |
            Kebab-case URL slug that matched. Echoed back so a client
            fanning out to several tag feeds can pair responses without
            juggling URLs.
          example: method
        posts:
          type: array
          items:
            $ref: '#/components/schemas/BlogFeedEntry'
          description: |
            Every post carrying this tag. No pagination — the per-tag
            count is bounded by the full journal size. Newest first by
            date, ties broken by slug (same rule as /api/blog/all.json).
        generated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp the response was built.
        total:
          type: integer
          minimum: 0
          description: posts.length convenience field.
    BlogTagCount:
      type: object
      description: |
        One row in the /api/blog/tag-counts.json map. Mirrors
        BlogTagCount in src/lib/types/blog-tag-counts.ts &mdash; keep both
        in sync when fields change.
      required: [slug, label, post_count]
      properties:
        slug:
          type: string
          description: |
            Kebab-case slug from src/lib/blog-archive.ts `tagToSlug()`.
            Same URL vocabulary as /api/blog/by-tag/{tag}.json and
            /blog/tag/{tag}.
          example: brisket
        label:
          type: string
          description: |
            Canonical display label as authored in
            src/data/blog-posts.ts ("Method", "Mug Club"). First seen
            wins so a label tweak in the manifest surfaces verbatim.
          example: Brisket
        post_count:
          type: integer
          minimum: 1
          description: |
            Count of active posts (drafts/noindex slugs excluded).
            Zero-counts are never emitted; a tag with no active posts
            is dropped from the map entirely.
    BlogTagCountsResponse:
      type: object
      description: |
        Top-level body of GET /api/blog/tag-counts.json. Mirrors
        BlogTagCountsResponse in src/lib/types/blog-tag-counts.ts &mdash;
        keep both in sync when fields change.

        Lightweight nav/breadcrumb-friendly mirror of the per-tag
        feed family. Same source of truth (src/data/blog-posts.ts)
        and same tag normalization helpers (tagToSlug in
        src/lib/blog-archive.ts) so the count surface cannot drift
        from the per-tag feeds.
      required: [tags, total_posts, generated_at]
      properties:
        tags:
          type: array
          items:
            $ref: '#/components/schemas/BlogTagCount'
          description: |
            One row per active tag. Sorted by post_count DESC, then
            slug ASC for stable output across builds.
        total_posts:
          type: integer
          minimum: 0
          description: |
            Count of active posts the map was built from
            (drafts/noindex slugs excluded). A consumer can show
            "26 posts across 14 tags" without iterating.
        generated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp the response was built.
