openapi: 3.1.0

info:
  title: michiyomi API
  version: "1.0.0"
  summary: Mapillary街路画像をVLMで構造化テキスト化した「座標×言語化」の配信API
  description: |
    東京23区の街路画像(Mapillary)を VLM が**画像に写っているものだけ**から言語化した
    構造化テキストを、座標から引くための read 専用API。認証なし・write系エンドポイントなし。

    ## 読む前に知っておくべきこと

    - 各記述は「その撮影年・その画角に見えた物理的状態」であって、地名や施設の同定結果ではない。
    - 同一地点に複数の**言語化世代(generation)**が共存する。既定は `quality=released`
      (検証済み世代のみ)。experimental世代は `quality` / `generation` の明示指定でのみ返る。
      released→experimental の自動フォールバックはしない。
    - 撮影時刻が異常なシーン(`quarantined=1`)は近傍検索・被覆統計から除外され、
      `GET /v1/scenes/{id}` の直接参照でのみ取得できる。
      ただし `/v1/meta` のリリース件数は隔離シーンを**含む**総数であり、
      `year_min` / `year_max` のみ隔離を除外して算出する(`counting_notes` 参照)。
    - **0件は0件として返す。**「変化が無かった」ではなく「その範囲・その品質ポリシーでは収録が無い」。
    - 経年変化(`/v1/changes/*`)の裁定は
      「同一モデル系列の文脈分離した別セッションによる反証優先の再検証」。
      検出側と検証側は同じモデル系列であり、共有された系統誤差は残りうる。
      `supported` 率は反証パスの生存率であって精度の下限保証ではない。
    - `segment_priority_score` は道路区間の点検優先度ヒューリスティックであり、
      変化そのものの危険度ではない(多くの行で `null`)。
    - Mapillary image ID は JSON でも **string** のまま扱う(number へ変換しない)。
    - `creator_id` / `creator_name` / `camera_make` / `camera_model` は内部来歴用であり
      公開レスポンスには一切含まれない。

    ## ライセンスと出典表示

    CC BY-SA 4.0(Mapillary由来)。派生物にも継承される。
    本データを表示するUIには Mapillaryロゴの視認可能な表示と
    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: 実装契約 (CONTRACT_2026-08-21.md)
  url: https://michiyomi.dev/

tags:
  - name: scenes
    description: シーン(街路画像1枚)の言語化
  - name: changes
    description: 裁定済み経年変化
  - name: schools
    description: 小学校位置(国土数値情報P29)
  - name: catalog
    description: リリース・世代・来歴のメタ情報
  - name: mcp
    description: Model Context Protocol エンドポイント

paths:
  /:
    get:
      tags: [catalog]
      summary: 人間向けランディングページ
      description: |
        ブラウザ利用者向けのHTMLページ。API概要、ライブExplorer、REST/MCP導線、
        Mapillary帰属表示を提供する。APIの機械可読な自己記述JSONは `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自己記述JSON
      description: エンドポイント一覧・alias・配信ポリシー・ライセンスを機械可読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: リリース件数と世代カタログ
      description: |
        件数は `release_stats`(リリース時に確定した正本)から返す。リクエスト時に全表集計はしない。
        件数・年範囲はすべて quarantine 除外で算出済み。
      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: 行収録率と、配信対象における主要機械特徴列の充足率
                        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: release_stats の生の metric→value マップ
                        additionalProperties: { type: integer }
                      counting_notes: { type: object, additionalProperties: { type: string } }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }

  /v1/scenes/nearby:
    get:
      tags: [scenes]
      summary: 近傍シーン検索
      description: |
        cell の y帯を**検索中心の緯度に近い順**に1帯ずつ取得し、Haversineで正確な円形フィルタをかける。
        順序は **丸め前のHaversine実距離 ASC → 実距離同値時 capture_year DESC → id ASC**。
        `distance_m` はレスポンス表示用にのみ整数化する。

        候補が上限(帯内8000/累積8000)に達した場合は `candidate_truncated=true`。
        打ち切られるのは常に検索中心から遠い帯なので、**密集地でも真の最近傍は失われない**。
        `quarantined=1` のシーンは返らない。
      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(0件でも200)
          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: 互換alias — GET /v1/scenes/nearby
      description: 旧API互換。`radius` → `radius_m`、`gen` → `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: GET /v1/scenes/nearby と同一
          content:
            application/json:
              schema: { type: object }
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/scenes/{id}:
    get:
      tags: [scenes]
      summary: シーン詳細
      description: |
        `include` 省略時は `analysis`(旧 `/v1/node/{id}` 互換)。

        - 指定 quality に該当する言語化が無い場合も **200** を返し、`verbalization: null` +
          `note` + `available_generations` で状況を開示する(自動フォールバックはしない)。
        - `analysis` が壊れている場合は 500 にせず `analysis: null` / `analysis_status: "invalid"`。
        - `quarantined` なシーンもIDの直接参照なら 200 で返る(`capture_time_quality` と `note` 付き)。
      parameters:
        - name: id
          in: path
          required: true
          description: Mapillary image ID(string)
          schema: { type: string }
        - name: include
          in: query
          description: |
            追加取得する層のカンマ区切り。未知値・空文字は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: 互換alias — GET /v1/scenes/{id}
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: GET /v1/scenes/{id} と同一
          content:
            application/json:
              schema: { type: object }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/coverage:
    get:
      tags: [scenes]
      summary: 地点の被覆申告(正直性ツール)
      description: |
        「この地点にどれだけ根拠があるか」を返す。0件は0件として返し、
        収録の欠如を現地の状態の否定にすり替えない。

        `scenes_by_year` / `scenes_by_generation` / `scenes_total` は指定 quality で畳んだ結果。
        `released_count` / `experimental_count` は品質ポリシーに関わらず
        「この半径に実在する言語化」の実数。`n_changes` は `status='supported'` のみ。
        すべて 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: キーは撮影年、または撮影年不明を表す "unknown"
                      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: 近傍の裁定済み経年変化
      description: |
        既定は `status=supported`(反証優先の再検証を通過したもの)。
        `evidence` は200字で打ち切り、`evidence_truncated` を立てる(全文は詳細エンドポイント)。
        座標は変化地点そのものではなく道路グループ(`group_id`)の中心。
        順序は distance_m ASC → year_b DESC → change_id ASC。

        `quality` / `generation` は他の入口と同じ厳格さで**検証する**(未知値は400)が、
        経年変化は言語化世代とは別レイヤなので結果の絞り込みには使わない
        (`quality_policy` と `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: 経年変化の詳細
      description: |
        `evidence` 全文と `verification`(反証優先の再検証メタ)を返す。
        `scene_ids_a` / `scene_ids_b` は元データに存在しないため本リリースでは全行 `null`
        (捏造しない。`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: 小学校名の部分一致検索
      description: |
        `q` を NFKC → lower → 空白(半角/全角)除去 で正規化し、
        `%` `_` `\` をエスケープした上で `LIKE '%q%' ESCAPE '\'` で照合する。
        `ORDER BY name_norm ASC, school_id ASC LIMIT 10`。0件は 200 + 空配列。
      parameters:
        - name: q
          in: query
          required: true
          description: 学校名の一部(1〜100文字)
          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: 言語化世代のカタログとガバナンス
      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: リリースmanifestと来歴
      description: |
        `provenance` は Rootline ノード連鎖。**released世代 gen1-codex に限った記録**であり、
        gen2-qwen-local には適用されない(`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: 互換alias — GET /v1/provenance
      responses:
        "200":
          description: GET /v1/provenance と同一
          content:
            application/json:
              schema: { type: object }

  /mcp:
    post:
      tags: [mcp]
      summary: MCP (Model Context Protocol) Streamable HTTP
      description: |
        stateless Streamable HTTP。SSEもセッションIDも使わず、JSON単発応答のみを返す。
        プロトコルは **2025-06-18 互換**。

        ツール: `find_school` / `coverage` / `describe_location` / `get_scene` / `changes_near`。
        `find_school` 以外は REST と同一 enum の `quality` 引数を持ち、既定は `released`
        (released→experimental の自動フォールバックはしない)。

        `initialize` は **このサーバが実際に話せるバージョン**
        (`2025-06-18` / `2025-03-26` / `2024-11-05`)を要求されたときだけそれをエコーし、
        それ以外は `2025-06-18` を返す(未確認の将来仕様への対応は宣言しない)。

        制限:

        | 条件 | 応答 |
        |---|---|
        | body > 64KB | HTTP 413 `payload_too_large` |
        | malformed JSON | HTTP 400 / JSON-RPC `-32700` |
        | 空batch | HTTP 400 / JSON-RPC `-32600` |
        | batch > 20 | HTTP 400 / JSON-RPC `-32600` |
        | batch内の `tools/call` N件 | RL_MCP を N 回消費(1つでも超過なら HTTP 429) |
        | `id` メンバー無し(通知) | HTTP 202(本文なし)・ツールは実行されない |
        | unknown method | JSON-RPC `-32601` |
        | unknown tool | `result.isError = true`(D1へ到達しない) |

        エラーに stack / SQL / 内部path は含めない。
      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応答(単発またはbatch)
          headers:
            Cache-Control: { $ref: "#/components/headers/CacheNone" }
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                  - type: array
                    items: { type: object }
        "202":
          description: 通知のみのため本文なし
        "400":
          description: parse error / invalid request(JSON-RPCエラー形)
          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: 非対応(SSEストリームは提供しない)
      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: 許可メソッド
      schema: { type: string }
    RetryAfter:
      description: 再試行までの秒数
      schema: { type: integer }

  parameters:
    lat:
      name: lat
      in: query
      required: true
      description: 緯度(WGS84)。`/^-?\d+(\.\d+)?$/` の厳格parse。
      schema: { type: number, minimum: -90, maximum: 90 }
    lon:
      name: lon
      in: query
      required: true
      description: 経度(WGS84)
      schema: { type: number, minimum: -180, maximum: 180 }
    radiusScene:
      name: radius_m
      in: query
      description: 検索半径(m)。整数のみ。
      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: 返す件数。scenes既定10 / changes既定20。
      schema: { type: integer, minimum: 1, maximum: 50 }
    quality:
      name: quality
      in: query
      description: |
        言語化世代の品質ポリシー。
        - `released`(既定): status='released' の世代のみ。地点ごとに version 最大
        - `include_experimental`: released(version降順) → experimental(version降順) の優先で地点ごとに1つ
        - `experimental_only`: experimental のみ
      schema:
        type: string
        enum: [released, include_experimental, experimental_only]
        default: released
    generation:
      name: generation
      in: query
      description: 世代IDを明示指定(`quality` より優先。存在しないIDは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: "`year_from` <= `year_to` でなければ400"
      schema: { type: integer, minimum: 2000, maximum: 2100 }
    category:
      name: category
      in: query
      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: 旧パラメータ。`radius_m` の alias(両方あれば `radius_m` が優先)。
      schema: { type: integer }
    legacyGen:
      name: gen
      in: query
      deprecated: true
      description: 旧パラメータ。`generation` の alias。
      schema: { type: string }
    legacyEra:
      name: era
      in: query
      deprecated: true
      description: |
        旧パラメータ。**受理するが結果には影響しない。**
        `warnings` 配列に "unsupported in this release" を返す。
      schema: { type: string }
    legacyMinScore:
      name: min_score
      in: query
      deprecated: true
      description: 旧パラメータ。`era` と同様、受理するが結果には影響しない。
      schema: { type: integer }

  responses:
    BadRequest:
      description: パラメータ不正(負数・NaN・Infinity・空文字・非数値文字列・未知enum・範囲外)
      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: 対象が存在しない
      headers:
        Cache-Control: { $ref: "#/components/headers/CacheNone" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    MethodNotAllowed:
      description: |
        read専用API。`/v1` への POST/PUT/DELETE/PATCH と `GET /mcp` は常に405。
        admin/write エンドポイントは存在しない。
      headers:
        Allow: { $ref: "#/components/headers/Allow" }
        Cache-Control: { $ref: "#/components/headers/CacheNone" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    PayloadTooLarge:
      description: リクエストボディが64KBを超えた
      headers:
        Cache-Control: { $ref: "#/components/headers/CacheNone" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    RateLimited:
      description: |
        レート制限超過。REST: 300req/60s/IP、MCP: 120req/60s/IP。
      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: |
        内部エラー。**stack / SQL / 内部path は返さない。**
        詳細は `request_id` 付きでサーバログ(console.error)にのみ記録される。
      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: crypto.randomUUID()。全応答とログに載る。

    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: 表示UIに Mapillaryロゴと 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: |
            experimental世代の行が1件でも含まれる場合 true。
            true のときはトップレベル `note` でも明示される。

    Envelope:
      type: object
      description: 全 /v1 成功応答に載る共通項目。
      required: [api_version, snapshot, request_id, data_as_of, quality_policy, license]
      properties:
        api_version: { type: string, const: v1 }
        snapshot:
          type: [string, "null"]
          description: 公開(なければstaged)最新リリースの release_id
        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: |
            正直性のための注記(experimental混入・0件・被覆不足・データ不整合など)。
            「0件だから変化が無かった」という断定は行わない。
        warnings:
          type: array
          items: { type: string }
          description: 受理したが無視した旧パラメータなど

    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。**number に変換しないこと。**
        distance_m: { type: integer }
        lat: { type: number }
        lon: { type: number }
        position_source:
          type: string
          enum: [mapillary_computed, mapillary_raw]
          description: 代表座標の出所。computed は Mapillary の推定補正位置、raw は GPS生値。
        capture_year: { type: [integer, "null"] }
        year:
          type: [integer, "null"]
          description: capture_year の別名(MCP/旧クライアント互換)
        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 }
        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: |
                    言語化全文(構造化JSON)。`include` に `analysis` がある場合のみ存在。
                    `analysis_status = "invalid"` のときは `null`(500にはしない)。
                  type: [object, "null"]
        metadata:
          description: "`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: raw座標とcomputed座標の距離(m)。どちらか欠損なら null。
            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: |
            `include=machine` のときのみ。機械層(画像処理由来の客観指標)。
            収録が無ければ `null`。**VLM言語化とは別namespace** であり混ぜて解釈しない。
          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: 道路グループID(旧gid) }
        distance_m: { type: integer }
        lat: { type: number, description: 道路グループ中心座標(変化地点そのものではない) }
        lon: { type: number }
        year_a: { type: integer }
        year_b: { type: integer }
        category:
          type: string
          enum: [設備, 建物, 区画線・標示, 舗装, 沿道用途, 植栽, その他]
        subject: { type: string, description: 変化の主題(旧what) }
        evidence: { type: [string, "null"], description: 200字で打ち切り }
        evidence_truncated: { type: boolean }
        segment_priority_score:
          type: [integer, "null"]
          description: |
            道路区間の点検優先度ヒューリスティック(旧max_risk)。
            **変化そのものの危険度ではない。** 多くの行で null。
        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: 全文 }
            scene_ids_a:
              type: [array, "null"]
              items: { type: string }
              description: 元データに存在しないため本リリースでは常に null(捏造しない)
            scene_ids_b:
              type: [array, "null"]
              items: { type: string }
            verification:
              type: [object, "null"]
              description: |
                裁定メタ。`method_ja` は
                「同一モデル系列の文脈分離した別セッションによる反証優先の再検証」。
              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 }
        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: 対象パラメータ名(あれば) }
            request_id: { $ref: "#/components/schemas/RequestId" }
            retry_after: { type: integer, description: 429のみ }

    JsonRpcMessage:
      type: object
      required: [jsonrpc, method]
      properties:
        jsonrpc: { type: string, const: "2.0" }
        id:
          description: |
            **"id" メンバーが無いものが通知**(JSON-RPC 2.0 §4.1)。サーバは一切応答せず、
            `tools/call` であってもツールは実行されない(HTTP 202・本文なし)。
            `"id": null` は「idメンバーがある」= リクエストであり、`null` がエコーされる。
          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: {}
