openapi: 3.1.0

info:
  title: michiyomi API
  version: "1.0.0"
  summary: Coordinate-searchable structured streetscape descriptions derived from Mapillary imagery with VLMs
  description: |
    A read-only API for querying structured descriptions of Tokyo street imagery (Mapillary) by coordinate.
    A VLM verbalizes **only what is visible in each image**. No authentication is required and no write
    endpoints exist.

    ## Before using the data

    - Streetscape narratives and change evidence are currently returned in **Japanese**. Interface metadata,
      field names, and this document are in English.
    - Each description represents the physical state visible from one camera angle in its capture year. It is
      not an identification of a place or facility.
    - Multiple **verbalization generations** may coexist at the same location. The default is
      `quality=released`. Experimental generations are returned only when explicitly selected with `quality`
      or `generation`; the API never falls back automatically from released to experimental.
    - Scenes with anomalous capture timestamps (`quarantined=1`) are excluded from nearby search and coverage
      statistics and can be retrieved only by direct `GET /v1/scenes/{id}` lookup. Release totals in `/v1/meta`
      include quarantined scenes; only `year_min` and `year_max` exclude them (see `counting_notes`).
    - **Zero results are returned as zero.** Zero means the requested area and quality policy have no matching
      record; it does not mean no real-world feature or change exists.
    - Longitudinal-change adjudication (`/v1/changes/*`) is a falsification-oriented re-evaluation by a separate,
      context-isolated session from the same model family. Shared systematic error can remain. The `supported`
      rate is survival under attempted falsification, not an accuracy guarantee.
    - `segment_priority_score` is a road-segment inspection-priority heuristic, not the severity or danger of
      the change itself; it is `null` for many rows.
    - Keep every Mapillary image ID as a JSON **string**; converting it to a number can lose precision.
    - `creator_id`, `creator_name`, `camera_make`, and `camera_model` are internal provenance fields and never
      appear in public responses.

    ## License and attribution

    CC BY-SA 4.0 (derived from Mapillary); share-alike applies to derived works. A UI displaying this data
    must show a visible Mapillary logo and link to https://www.mapillary.com.
  license:
    name: CC BY-SA 4.0
    identifier: CC-BY-SA-4.0
  contact:
    name: michiyomi
    url: https://michiyomi.dev/

servers:
  - url: https://michiyomi.dev
    description: production

externalDocs:
  description: English developer guide
  url: https://michiyomi.dev/en/docs/

tags:
  - name: scenes
    description: Verbalization of individual street-image scenes; narrative content is Japanese
  - name: changes
    description: Adjudicated longitudinal changes; narratives and enum values are Japanese
  - name: schools
    description: Elementary-school locations from National Land Numerical Information P29
  - name: catalog
    description: Release, generation, and provenance metadata
  - name: mcp
    description: Model Context Protocol endpoint

paths:
  /:
    get:
      tags: [catalog]
      summary: Human-facing landing page
      description: |
        HTML landing page for browser users, including an API overview, live explorer, REST/MCP links,
        and Mapillary attribution. The machine-readable API self-description is `GET /v1`.
      responses:
        "200":
          description: OK
          headers:
            Cache-Control: { $ref: "#/components/headers/CacheMeta" }
          content:
            text/html:
              schema:
                type: string
        "405": { $ref: "#/components/responses/MethodNotAllowed" }

  /v1:
    get:
      tags: [catalog]
      summary: API self-description JSON
      description: Returns the endpoint catalog, aliases, serving policies, and license as machine-readable JSON.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  api_version: { type: string, const: v1 }
                  name: { type: string }
                  description: { type: string }
                  endpoints: { type: object, additionalProperties: { type: string } }
                  aliases: { type: object, additionalProperties: { type: string } }
                  policies: { type: array, items: { type: string } }
                  request_id: { $ref: "#/components/schemas/RequestId" }
                  license: { $ref: "#/components/schemas/License" }

  /v1/meta:
    get:
      tags: [catalog]
      summary: Release counts and generation catalog
      description: |
        Counts come from `release_stats`, the canonical values fixed at release time; the service does not scan
        every table at request time. Counts and year ranges are precomputed with quarantine excluded except where
        `counting_notes` explicitly states otherwise.
      responses:
        "200":
          description: OK
          headers:
            Cache-Control: { $ref: "#/components/headers/CacheMeta" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      release:
                        oneOf:
                          - $ref: "#/components/schemas/Release"
                          - type: "null"
                      counts:
                        type: object
                        properties:
                          processed_total: { type: [integer, "null"] }
                          served_total: { type: [integer, "null"] }
                          released_total: { type: [integer, "null"] }
                          experimental_total: { type: [integer, "null"] }
                          changes_total: { type: [integer, "null"] }
                          schools_total: { type: [integer, "null"] }
                          quarantined_total: { type: [integer, "null"] }
                          machine_feature_rows: { type: [integer, "null"] }
                          position_source:
                            type: object
                            properties:
                              mapillary_computed: { type: [integer, "null"] }
                              mapillary_raw: { type: [integer, "null"] }
                      machine_feature_coverage:
                        type: object
                        description: Row coverage and populated-field rates for major machine features in served records
                        additionalProperties: true
                      years:
                        type: object
                        properties:
                          min: { type: [integer, "null"] }
                          max: { type: [integer, "null"] }
                      generations:
                        type: array
                        items: { $ref: "#/components/schemas/GenerationCatalogEntry" }
                      metrics:
                        type: object
                        description: Raw metric-to-value map from release_stats
                        additionalProperties: { type: integer }
                      counting_notes: { type: object, additionalProperties: { type: string } }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }

  /v1/scenes/nearby:
    get:
      tags: [scenes]
      summary: Search nearby scenes
      description: |
        Retrieves grid-cell y-bands one at a time in order of proximity to the query latitude, then applies an
        exact circular Haversine filter. Results are ordered by **unrounded Haversine distance ASC, capture_year
        DESC for equal distances, then id ASC**. `distance_m` is rounded only for display.

        When the candidate limit is reached (8,000 within a band or cumulatively),
        `candidate_truncated=true`. Truncation always removes bands farther from the query center, so the true
        nearest records are retained even in dense areas. Scenes with `quarantined=1` are excluded.
      parameters:
        - $ref: "#/components/parameters/lat"
        - $ref: "#/components/parameters/lon"
        - $ref: "#/components/parameters/radiusScene"
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/quality"
        - $ref: "#/components/parameters/generation"
        - $ref: "#/components/parameters/yearFrom"
        - $ref: "#/components/parameters/yearTo"
        - $ref: "#/components/parameters/legacyRadius"
        - $ref: "#/components/parameters/legacyGen"
        - $ref: "#/components/parameters/legacyEra"
        - $ref: "#/components/parameters/legacyMinScore"
      responses:
        "200":
          description: OK (200 is returned even for zero results)
          headers:
            Cache-Control: { $ref: "#/components/headers/CacheSearch" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      query: { $ref: "#/components/schemas/SceneQuery" }
                      count: { type: integer }
                      results:
                        type: array
                        items: { $ref: "#/components/schemas/SceneSummary" }
                      candidate_truncated: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }

  /v1/nearby:
    get:
      tags: [scenes]
      summary: Compatibility alias for GET /v1/scenes/nearby
      description: Legacy compatibility. Accepts `radius` as `radius_m` and `gen` as `generation`.
      parameters:
        - $ref: "#/components/parameters/lat"
        - $ref: "#/components/parameters/lon"
        - $ref: "#/components/parameters/legacyRadius"
        - $ref: "#/components/parameters/legacyGen"
        - $ref: "#/components/parameters/legacyEra"
        - $ref: "#/components/parameters/legacyMinScore"
      responses:
        "200":
          description: Same response as GET /v1/scenes/nearby
          content:
            application/json:
              schema: { type: object }
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/scenes/{id}:
    get:
      tags: [scenes]
      summary: Scene detail
      description: |
        When `include` is omitted, only `analysis` is requested (compatible with legacy `/v1/node/{id}`).

        - If no verbalization matches the selected quality policy, the endpoint still returns **200** with
          `verbalization: null`, a `note`, and `available_generations`; it never falls back automatically.
        - Invalid stored analysis returns `analysis: null` and `analysis_status: "invalid"`, not a 500 response.
        - A quarantined scene is returned with 200 when looked up directly by ID, together with
          `capture_time_quality` and a note.
      parameters:
        - name: id
          in: path
          required: true
          description: Mapillary image ID(string)
          schema: { type: string }
        - name: include
          in: query
          description: |
            Comma-separated layers to retrieve. Unknown values or an empty string return 400.
          schema:
            type: string
            default: analysis
            examples: ["analysis", "metadata,machine,analysis"]
        - $ref: "#/components/parameters/quality"
        - $ref: "#/components/parameters/generation"
        - $ref: "#/components/parameters/legacyGen"
      responses:
        "200":
          description: OK
          headers:
            Cache-Control: { $ref: "#/components/headers/CacheDetail" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - $ref: "#/components/schemas/SceneDetail"
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }

  /v1/node/{id}:
    get:
      tags: [scenes]
      summary: Compatibility alias for GET /v1/scenes/{id}
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Same response as GET /v1/scenes/{id}
          content:
            application/json:
              schema: { type: object }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/coverage:
    get:
      tags: [scenes]
      summary: Disclose data coverage at a point
      description: |
        Reports how much evidence exists near a point. Zero results remain zero; missing dataset coverage must
        never be reinterpreted as evidence that a real-world feature or change is absent.

        `scenes_by_year`, `scenes_by_generation`, and `scenes_total` are collapsed under the selected quality
        policy. `released_count` and `experimental_count` are actual verbalization counts within the radius,
        independent of the requested quality policy. `n_changes` counts only `status='supported'`. All exclude
        quarantine.
      parameters:
        - $ref: "#/components/parameters/lat"
        - $ref: "#/components/parameters/lon"
        - $ref: "#/components/parameters/radiusCoverage"
        - $ref: "#/components/parameters/quality"
        - $ref: "#/components/parameters/generation"
        - $ref: "#/components/parameters/yearFrom"
        - $ref: "#/components/parameters/yearTo"
        - $ref: "#/components/parameters/legacyRadius"
      responses:
        "200":
          description: OK
          headers:
            Cache-Control: { $ref: "#/components/headers/CacheSearch" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      query: { type: object }
                      scenes_total: { type: integer }
                      scenes_by_year:
                        type: object
                        additionalProperties: { type: integer }
                        description: Keys are capture years, or "unknown" when the capture year is unavailable
                      scenes_by_generation:
                        type: object
                        additionalProperties: { type: integer }
                      released_count: { type: integer }
                      experimental_count: { type: integer }
                      latest_year: { type: [integer, "null"] }
                      n_changes: { type: integer }
                      candidate_truncated: { type: boolean }
                      counting_notes: { type: object, additionalProperties: { type: string } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/changes/nearby:
    get:
      tags: [changes]
      summary: Search nearby adjudicated longitudinal changes
      description: |
        The default is `status=supported`, meaning the claim survived falsification-oriented re-evaluation.
        `evidence` is truncated to 200 Japanese characters and sets `evidence_truncated`; use the detail endpoint
        for the full text. Coordinates are the center of the road group (`group_id`), not the exact changed object.
        Ordering is distance_m ASC, then year_b DESC, then change_id ASC.

        `quality` and `generation` are validated as strictly as on other endpoints (unknown values return 400),
        but longitudinal changes are a separate layer from verbalization generations, so these parameters do not
        filter results; they are reflected only in `quality_policy` and `field_notes.quality`.
      parameters:
        - $ref: "#/components/parameters/lat"
        - $ref: "#/components/parameters/lon"
        - $ref: "#/components/parameters/radiusChange"
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/category"
        - $ref: "#/components/parameters/changeStatus"
        - $ref: "#/components/parameters/yearFrom"
        - $ref: "#/components/parameters/yearTo"
        - $ref: "#/components/parameters/quality"
        - $ref: "#/components/parameters/generation"
        - $ref: "#/components/parameters/legacyRadius"
      responses:
        "200":
          description: OK
          headers:
            Cache-Control: { $ref: "#/components/headers/CacheSearch" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      query: { type: object }
                      count: { type: integer }
                      results:
                        type: array
                        items: { $ref: "#/components/schemas/ChangeSummary" }
                      candidate_truncated: { type: boolean }
                      field_notes: { type: object, additionalProperties: { type: string } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/changes/{change_id}:
    get:
      tags: [changes]
      summary: Longitudinal-change detail
      description: |
        Returns full Japanese `evidence` and `verification` metadata from falsification-oriented re-evaluation.
        `scene_ids_a` and `scene_ids_b` are `null` for every row in this release because the source data does not
        contain them. The service does not fabricate IDs and discloses this in `note`.
      parameters:
        - name: change_id
          in: path
          required: true
          schema: { type: string, pattern: "^chg_[0-9a-f]{12}$" }
      responses:
        "200":
          description: OK
          headers:
            Cache-Control: { $ref: "#/components/headers/CacheDetail" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      change: { $ref: "#/components/schemas/ChangeDetail" }
                      field_notes: { type: object, additionalProperties: { type: string } }
        "404": { $ref: "#/components/responses/NotFound" }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }

  /v1/schools/search:
    get:
      tags: [schools]
      summary: Search elementary schools by partial Japanese name
      description: |
        Normalizes `q` with NFKC, lowercase conversion, and removal of half-width and full-width spaces; escapes
        `%`, `_`, and `\`; then matches with `LIKE '%q%' ESCAPE '\'`. Results use
        `ORDER BY name_norm ASC, school_id ASC LIMIT 10`. Zero matches return 200 and an empty array.
      parameters:
        - name: q
          in: query
          required: true
          description: Part of a Japanese school name (1–100 characters)
          schema: { type: string, minLength: 1, maxLength: 100 }
      responses:
        "200":
          description: OK
          headers:
            Cache-Control: { $ref: "#/components/headers/CacheMeta" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      query: { type: string }
                      normalized_query: { type: string }
                      count: { type: integer, maximum: 10 }
                      results:
                        type: array
                        maxItems: 10
                        items: { $ref: "#/components/schemas/School" }
                      source: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }

  /v1/generations:
    get:
      tags: [catalog]
      summary: Verbalization-generation catalog and governance
      responses:
        "200":
          description: OK
          headers:
            Cache-Control: { $ref: "#/components/headers/CacheMeta" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      default_generation: { type: [string, "null"] }
                      governance: { type: string }
                      generations:
                        type: array
                        items: { $ref: "#/components/schemas/GenerationCatalogEntry" }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }

  /v1/provenance:
    get:
      tags: [catalog]
      summary: Release manifest and provenance
      description: |
        `provenance` is the Rootline node chain. It records **only the released gen1-codex generation** and does
        not apply to gen2-qwen-local, as disclosed by `provenance_scope`.
      responses:
        "200":
          description: OK
          headers:
            Cache-Control: { $ref: "#/components/headers/CacheMeta" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      release:
                        oneOf:
                          - type: object
                          - type: "null"
                      provenance: { type: object }
                      provenance_scope: { type: string }
                      verification_method: { type: string }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }

  /v1/prov:
    get:
      tags: [catalog]
      summary: Compatibility alias for GET /v1/provenance
      responses:
        "200":
          description: Same response as GET /v1/provenance
          content:
            application/json:
              schema: { type: object }

  /mcp:
    post:
      tags: [mcp]
      summary: MCP (Model Context Protocol) Streamable HTTP
      description: |
        Stateless Streamable HTTP. The endpoint returns one JSON response and uses neither SSE nor session IDs.
        It is compatible with protocol version **2025-06-18**.

        Tools: `find_school`, `coverage`, `describe_location`, `get_scene`, and `changes_near`.
        All except `find_school` accept the same `quality` enum as REST, defaulting to `released`; there is no
        automatic fallback from released to experimental. Tool discovery is in English, while returned
        streetscape narratives and change evidence are Japanese.

        `initialize` echoes a requested version only when it is one the server actually supports:
        `2025-06-18`, `2025-03-26`, or `2024-11-05`. Otherwise it returns `2025-06-18` and does not claim support
        for unverified future specifications.

        Limits:

        | Condition | Response |
        |---|---|
        | body > 64KB | HTTP 413 `payload_too_large` |
        | malformed JSON | HTTP 400 / JSON-RPC `-32700` |
        | empty batch | HTTP 400 / JSON-RPC `-32600` |
        | batch > 20 | HTTP 400 / JSON-RPC `-32600` |
        | N `tools/call` entries in a batch | consumes RL_MCP N times; HTTP 429 if any call exceeds the limit |
        | no `id` member (notification) | HTTP 202 with no body; the tool is not executed |
        | unknown method | JSON-RPC `-32601` |
        | unknown tool | `result.isError = true`; D1 is not queried |

        Errors never expose stack traces, SQL, or internal paths.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/JsonRpcMessage"
                - type: array
                  minItems: 1
                  maxItems: 20
                  items: { $ref: "#/components/schemas/JsonRpcMessage" }
      responses:
        "200":
          description: JSON-RPC response (single request or batch)
          headers:
            Cache-Control: { $ref: "#/components/headers/CacheNone" }
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                  - type: array
                    items: { type: object }
        "202":
          description: No response body because the request contained notifications only
        "400":
          description: Parse error or invalid request in JSON-RPC error form
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JsonRpcError" }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [mcp]
      summary: Not supported; no SSE stream is provided
      responses:
        "405": { $ref: "#/components/responses/MethodNotAllowed" }

components:
  headers:
    CacheMeta:
      description: "`public, max-age=300`"
      schema: { type: string }
    CacheDetail:
      description: "`public, max-age=86400`"
      schema: { type: string }
    CacheSearch:
      description: "`public, max-age=60`"
      schema: { type: string }
    CacheNone:
      description: "`no-store`"
      schema: { type: string }
    Allow:
      description: Allowed methods
      schema: { type: string }
    RetryAfter:
      description: Seconds before retrying
      schema: { type: integer }

  parameters:
    lat:
      name: lat
      in: query
      required: true
      description: Latitude (WGS84), parsed strictly with `/^-?\d+(\.\d+)?$/`
      schema: { type: number, minimum: -90, maximum: 90 }
    lon:
      name: lon
      in: query
      required: true
      description: Longitude (WGS84)
      schema: { type: number, minimum: -180, maximum: 180 }
    radiusScene:
      name: radius_m
      in: query
      description: Search radius in meters; integer only
      schema: { type: integer, minimum: 1, maximum: 1000, default: 150 }
    radiusCoverage:
      name: radius_m
      in: query
      schema: { type: integer, minimum: 1, maximum: 1000, default: 300 }
    radiusChange:
      name: radius_m
      in: query
      schema: { type: integer, minimum: 1, maximum: 3000, default: 500 }
    limit:
      name: limit
      in: query
      description: Number of records to return; default 10 for scenes and 20 for changes
      schema: { type: integer, minimum: 1, maximum: 50 }
    quality:
      name: quality
      in: query
      description: |
        Verbalization-generation quality policy.
        - `released` (default): only generations with status='released'; highest version per location
        - `include_experimental`: one per location, preferring released by descending version, then experimental
          by descending version
        - `experimental_only`: experimental generations only
      schema:
        type: string
        enum: [released, include_experimental, experimental_only]
        default: released
    generation:
      name: generation
      in: query
      description: Explicit generation ID; takes precedence over `quality`; an unknown ID returns 400
      schema: { type: string, maxLength: 64 }
      examples:
        gen1: { value: gen1-codex }
        gen2: { value: gen2-qwen-local }
    yearFrom:
      name: year_from
      in: query
      schema: { type: integer, minimum: 2000, maximum: 2100 }
    yearTo:
      name: year_to
      in: query
      description: Returns 400 unless `year_from` is less than or equal to `year_to`
      schema: { type: integer, minimum: 2000, maximum: 2100 }
    category:
      name: category
      in: query
      description: Japanese change category. Values mean equipment, building, road marking, pavement, roadside use, vegetation, and other.
      schema:
        type: string
        enum: [設備, 建物, 区画線・標示, 舗装, 沿道用途, 植栽, その他]
    changeStatus:
      name: status
      in: query
      schema:
        type: string
        enum: [supported, refuted, unverifiable, withdrawn]
        default: supported
    legacyRadius:
      name: radius
      in: query
      deprecated: true
      description: Legacy alias for `radius_m`; when both are present, `radius_m` takes precedence
      schema: { type: integer }
    legacyGen:
      name: gen
      in: query
      deprecated: true
      description: Legacy alias for `generation`
      schema: { type: string }
    legacyEra:
      name: era
      in: query
      deprecated: true
      description: |
        Legacy parameter. It is **accepted but does not affect results**. The `warnings` array reports
        "unsupported in this release".
      schema: { type: string }
    legacyMinScore:
      name: min_score
      in: query
      deprecated: true
      description: Legacy parameter. Like `era`, it is accepted but does not affect results.
      schema: { type: integer }

  responses:
    BadRequest:
      description: Invalid parameter (negative value, NaN, Infinity, empty string, nonnumeric text, unknown enum, or out of range)
      headers:
        Cache-Control: { $ref: "#/components/headers/CacheNone" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error:
              code: invalid_parameter
              message: radius_m must be between 1 and 1000
              field: radius_m
              request_id: 5d0d1d0e-3b1a-4c66-9a2f-1f0a5f1c9f11
    NotFound:
      description: The requested resource does not exist
      headers:
        Cache-Control: { $ref: "#/components/headers/CacheNone" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    MethodNotAllowed:
      description: |
        Read-only API. POST, PUT, DELETE, and PATCH to `/v1`, and `GET /mcp`, always return 405.
        No administrative or write endpoint exists.
      headers:
        Allow: { $ref: "#/components/headers/Allow" }
        Cache-Control: { $ref: "#/components/headers/CacheNone" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    PayloadTooLarge:
      description: Request body exceeds 64 KB
      headers:
        Cache-Control: { $ref: "#/components/headers/CacheNone" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    RateLimited:
      description: |
        Rate limit exceeded. REST allows 300 requests per 60 seconds per IP; MCP allows 120.
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
        Cache-Control: { $ref: "#/components/headers/CacheNone" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error:
              code: rate_limited
              message: rate limit exceeded, retry after 60 seconds
              request_id: 5d0d1d0e-3b1a-4c66-9a2f-1f0a5f1c9f11
              retry_after: 60
    Internal:
      description: |
        Internal error. Responses never expose stack traces, SQL, or internal paths. Details are recorded only
        in server logs with the `request_id`.
      headers:
        Cache-Control: { $ref: "#/components/headers/CacheNone" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error:
              code: internal
              message: internal error
              request_id: 5d0d1d0e-3b1a-4c66-9a2f-1f0a5f1c9f11

  schemas:
    RequestId:
      type: string
      format: uuid
      description: Generated by crypto.randomUUID(); included in every response and log entry

    License:
      type: object
      required: [id, attribution, mapillary, ui_requirements]
      properties:
        id: { type: string, const: CC-BY-SA-4.0 }
        attribution: { type: string }
        mapillary: { type: string, format: uri }
        ui_requirements:
          type: string
          description: Display UIs must show a visible Mapillary logo and link to https://www.mapillary.com

    QualityPolicy:
      type: object
      required: [requested, experimental_included]
      properties:
        requested:
          type: string
          enum: [released, include_experimental, experimental_only]
        generation: { type: [string, "null"] }
        experimental_included:
          type: boolean
          description: |
            True if at least one experimental-generation row is included. When true, the top-level `note`
            discloses this as well.

    Envelope:
      type: object
      description: Common fields included in every successful /v1 response
      required: [api_version, snapshot, request_id, data_as_of, quality_policy, license]
      properties:
        api_version: { type: string, const: v1 }
        snapshot:
          type: [string, "null"]
          description: release_id of the latest published release, or latest staged release if none is published
        request_id: { $ref: "#/components/schemas/RequestId" }
        data_as_of: { type: [string, "null"] }
        quality_policy: { $ref: "#/components/schemas/QualityPolicy" }
        license: { $ref: "#/components/schemas/License" }
        note:
          type: string
          description: |
            Honesty note covering experimental inclusion, zero results, sparse coverage, or data inconsistency.
            It never claims that zero records prove no real-world change occurred.
        warnings:
          type: array
          items: { type: string }
          description: Accepted but ignored legacy parameters and similar warnings

    GenerationRef:
      type: object
      required: [id, status, version]
      properties:
        id: { type: string }
        status: { type: string, enum: [experimental, released, deprecated] }
        version: { type: integer }

    GenerationCatalogEntry:
      allOf:
        - $ref: "#/components/schemas/GenerationRef"
        - type: object
          properties:
            gen_id: { type: string }
            model_family: { type: [string, "null"] }
            model: { type: [string, "null"] }
            prompt_version: { type: [string, "null"] }
            output_contract: { type: string }
            released_at: { type: [string, "null"] }
            notes: { type: [string, "null"] }
            evidence: { type: [object, "null"] }
            stats:
              type: object
              properties:
                rows: { type: integer }
                by_prompt_version:
                  type: object
                  additionalProperties: { type: integer }
                min_year: { type: [integer, "null"] }
                max_year: { type: [integer, "null"] }
                updated_at: { type: [string, "null"] }

    Release:
      type: object
      properties:
        release_id: { type: string }
        status: { type: string, enum: [building, staged, published, withdrawn] }
        created_at: { type: string }
        published_at: { type: [string, "null"] }
        data_as_of: { type: string }

    SceneQuery:
      type: object
      properties:
        lat: { type: number }
        lon: { type: number }
        radius_m: { type: integer }
        limit: { type: integer }
        year_from: { type: [integer, "null"] }
        year_to: { type: [integer, "null"] }

    SceneSummary:
      type: object
      required: [id, distance_m, lat, lon, position_source, capture_year, generation]
      properties:
        id:
          type: string
          description: Mapillary image ID. **Do not convert it to a number.**
        distance_m: { type: integer }
        lat: { type: number }
        lon: { type: number }
        position_source:
          type: string
          enum: [mapillary_computed, mapillary_raw]
          description: Source of the representative coordinate; computed is Mapillary-corrected, raw is GPS
        capture_year: { type: [integer, "null"] }
        year:
          type: [integer, "null"]
          description: Alias of capture_year for MCP and legacy-client compatibility
        ward: { type: [string, "null"] }
        view_class: { type: [string, "null"] }
        travel_bearing: { type: [number, "null"] }
        is_pano: { type: [boolean, "null"] }
        generation: { $ref: "#/components/schemas/GenerationRef" }
        model: { type: [string, "null"] }
        prompt_version: { type: [string, "null"] }
        summary: { type: string, description: Precomputed Japanese streetscape summary }
        analysis_status: { type: string, enum: [ok, invalid] }
        image_page: { type: string, format: uri }

    SceneDetail:
      type: object
      required: [scene, image_page, available_generations, include, verbalization]
      properties:
        scene:
          type: object
          properties:
            id: { type: string }
            lat: { type: number }
            lon: { type: number }
            position_source: { type: string, enum: [mapillary_computed, mapillary_raw] }
            capture_year: { type: [integer, "null"] }
            capture_time_quality: { type: string, enum: [ok, epoch_anomaly, unknown] }
            ward: { type: [string, "null"] }
            quarantined: { type: boolean }
        image_page: { type: string, format: uri }
        available_generations:
          type: array
          items: { $ref: "#/components/schemas/GenerationRef" }
        include:
          type: array
          items: { type: string, enum: [metadata, machine, analysis] }
        verbalization:
          oneOf:
            - type: "null"
            - type: object
              properties:
                generation: { $ref: "#/components/schemas/GenerationRef" }
                model: { type: [string, "null"] }
                prompt_version: { type: [string, "null"] }
                generated_at: { type: [string, "null"] }
                summary: { type: string }
                analysis_status: { type: string, enum: [ok, invalid] }
                content_sha256: { type: string }
                analysis:
                  description: |
                    Full Japanese verbalization as structured JSON. Present only when `include` contains
                    `analysis`. It is `null`, rather than causing a 500 response, when
                    `analysis_status = "invalid"`.
                  type: [object, "null"]
        metadata:
          description: Present only when `include=metadata`
          type: object
          properties:
            raw_lat: { type: [number, "null"] }
            raw_lon: { type: [number, "null"] }
            computed_lat: { type: [number, "null"] }
            computed_lon: { type: [number, "null"] }
            position_source: { type: string }
            position_offset_m:
              type: [number, "null"]
              description: Distance in meters between raw and computed coordinates; null if either is missing
            raw_compass: { type: [number, "null"] }
            computed_compass: { type: [number, "null"] }
            travel_bearing: { type: [number, "null"] }
            view_class: { type: [string, "null"] }
            sequence_id: { type: [string, "null"] }
            is_pano: { type: [boolean, "null"] }
            quality_score: { type: [number, "null"] }
            camera_type: { type: [string, "null"] }
            width: { type: [integer, "null"] }
            height: { type: [integer, "null"] }
            captured_at_ms: { type: [integer, "null"] }
            captured_at_jst: { type: [string, "null"] }
            capture_time_quality: { type: string }
            cell_250m: { type: integer }
            position_note: { type: string }
        machine:
          description: |
            Present only when `include=machine`. Contains objective indicators derived from image processing and
            is `null` when no machine row exists. This is a **separate namespace from VLM verbalization** and
            should not be blended with it during interpretation.
          oneOf:
            - type: "null"
            - type: object
              properties:
                node_id: { type: string }
                feature_version: { type: string }
                month: { type: [integer, "null"] }
                weekday: { type: [string, "null"] }
                hour: { type: [integer, "null"] }
                season: { type: [string, "null"] }
                time_bucket: { type: [string, "null"] }
                heading: { type: [number, "null"] }
                heading8: { type: [string, "null"] }
                left_side8: { type: [string, "null"] }
                right_side8: { type: [string, "null"] }
                abs_objects: {}
                colorfulness: { type: [number, "null"] }
                green_ratio: { type: [number, "null"] }
                warm_ratio: { type: [number, "null"] }
                dominant_colors: {}
                color_status: { type: [string, "null"] }
        machine_note: { type: string }

    ChangeSummary:
      type: object
      properties:
        change_id: { type: string }
        group_id: { type: integer, description: "Road-group ID, formerly gid" }
        distance_m: { type: integer }
        lat: { type: number, description: "Road-group center coordinate, not the exact changed-object location" }
        lon: { type: number }
        year_a: { type: integer }
        year_b: { type: integer }
        category:
          type: string
          enum: [設備, 建物, 区画線・標示, 舗装, 沿道用途, 植栽, その他]
          description: Japanese enum value for equipment, building, road marking, pavement, roadside use, vegetation, or other
        subject: { type: string, description: "Japanese change subject, formerly what" }
        evidence: { type: [string, "null"], description: Japanese evidence truncated to 200 characters }
        evidence_truncated: { type: boolean }
        segment_priority_score:
          type: [integer, "null"]
          description: |
            Road-segment inspection-priority heuristic, formerly max_risk. **This is not the danger or severity
            of the change itself.** It is null for many rows.
        status: { type: string, enum: [supported, refuted, unverifiable, withdrawn] }

    ChangeDetail:
      allOf:
        - $ref: "#/components/schemas/ChangeSummary"
        - type: object
          properties:
            cell_1km: { type: integer }
            evidence: { type: [string, "null"], description: Full Japanese evidence text }
            scene_ids_a:
              type: [array, "null"]
              items: { type: string }
              description: Always null in this release because the source data does not contain these IDs
            scene_ids_b:
              type: [array, "null"]
              items: { type: string }
            verification:
              type: [object, "null"]
              description: |
                Adjudication metadata. `method_ja` contains the Japanese method label meaning a
                falsification-oriented re-evaluation by a separate, context-isolated session from the same
                model family.
              properties:
                method: { type: string }
                method_ja: { type: string }
                detector_model: { type: string }
                reviewer_model: { type: string }
                same_model_family: { type: boolean }
                verdict: { type: string }
                limitations: { type: array, items: { type: string } }

    School:
      type: object
      properties:
        school_id: { type: string }
        name: { type: string, description: Japanese school name }
        lat: { type: number }
        lon: { type: number }

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code:
              type: string
              enum:
                [
                  invalid_parameter,
                  not_found,
                  method_not_allowed,
                  payload_too_large,
                  rate_limited,
                  internal,
                ]
            message: { type: string }
            field: { type: string, description: "Relevant parameter name, when available" }
            request_id: { $ref: "#/components/schemas/RequestId" }
            retry_after: { type: integer, description: Present only for 429 responses }

    JsonRpcMessage:
      type: object
      required: [jsonrpc, method]
      properties:
        jsonrpc: { type: string, const: "2.0" }
        id:
          description: |
            A message **without an `id` member is a notification** under JSON-RPC 2.0 §4.1. The server returns no
            JSON-RPC response and does not execute the tool, even for `tools/call` (HTTP 202, empty body).
            `"id": null` still has an `id` member and is therefore a request; null is echoed in the response.
          type: [string, integer, "null"]
        method:
          type: string
          examples: [initialize, ping, tools/list, tools/call, notifications/initialized]
        params: { type: object }

    JsonRpcError:
      type: object
      properties:
        jsonrpc: { type: string, const: "2.0" }
        id: { type: [string, integer, "null"] }
        error:
          type: object
          properties:
            code:
              type: integer
              description: "-32700 parse error / -32600 invalid request / -32601 method not found / -32602 invalid params"
            message: { type: string }
            data: {}
