openapi: 3.1.0
info:
  title: Veacon API
  version: '1.0'
  summary: Korean institutional commercial real-estate data API
  description: |
    REST API surface for the Veacon Korean CRE (commercial real estate)
    data infrastructure. Two verticals share the same auth + envelope:

      - `/v1/real-estate/*` — RTMS-grade aggregate (ADR-014 primary)
      - `/v1/markets/*` — workspace marketplace (Syncle-sourced)

    Plus three auth-only management surfaces under `/auth/me/*` for
    dashboard interactions (API keys, webhooks, team members).

    Authentication is dual-mode: `X-API-Key` header for B2B consumers
    or `veacon_session` cookie for Syncle SSO users. The `/auth/me/*`
    endpoints are cookie-only.

    Every `/v1/real-estate/*` response carries an ADR-015 Layer 1-3
    envelope (`coverage_note`, `source_mix`, `confidence_factors`,
    `disclosure_url`) — see https://veacon.io/data-trust.

    **Request tracing**: every response includes an `X-Request-ID`
    header (also mirrored at `_meta.request_id` on success and
    `error.request_id` on errors). Cite this ID when filing support
    tickets. Callers may also send `X-Request-ID` on the request to
    propagate their own trace context — valid IDs (`[A-Za-z0-9_-]`,
    8-128 chars) are preserved end-to-end; anything else is replaced.

    **CSV content negotiation**: `/real-estate/{pulse, pulse/series,
    coverage}` accept `Accept: text/csv` (or `?format=csv`) and emit
    RFC 4180 CSV with a UTF-8 BOM. Key envelope fields ride alongside
    as `X-Veacon-*` response headers. 4xx / 5xx always stay JSON
    regardless of negotiation. Full reference at /docs/api/csv-format.
  contact:
    name: Veacon Support
    email: hello@veacon.io
    url: https://veacon.io
  license:
    name: Proprietary
    url: https://veacon.io/terms
  termsOfService: https://veacon.io/terms

servers:
  - url: https://veacon.io
    description: Production

tags:
  - name: real-estate
    description: |
      RTMS-grade Korean CRE aggregate. Tier-gated geo precision (Pro+
      unlocks dong precision). See https://veacon.io/data-trust for the
      4-layer trust architecture documenting boundary disclosure +
      multi-source confidence.
  - name: marketplace
    description: |
      Workspace marketplace aggregate (Syncle-sourced). k-anonymity ≥ 3
      gate. Distinct trust model from real-estate.
  - name: account
    description: Cookie-authenticated dashboard endpoints.

security:
  - apiKey: []
  - sessionCookie: []

paths:
  /api/v1/real-estate/pulse:
    get:
      tags: [real-estate]
      summary: Aggregate pulse — quartile + sample + multi-source confidence
      description: |
        Returns `(P25, median, P75, avg)` over a sigungu × property_type ×
        transaction_type × period cohort, plus `source_mix` /
        `source_means` / `confidence_factors` exposing the multi-source
        agreement math (ADR-015 Layer 3).

        The Layer 1 envelope on `_meta` rides every response — including
        404. Tier gate: `geo_precision=dong` requires Pro tier or higher.
      parameters:
        - $ref: '#/components/parameters/SigunguCode'
        - $ref: '#/components/parameters/PropertyType'
        - $ref: '#/components/parameters/TransactionType'
        - $ref: '#/components/parameters/Period'
        - $ref: '#/components/parameters/GeoPrecision'
        - $ref: '#/components/parameters/Dong'
      responses:
        '200':
          description: Cohort aggregate with envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RealEstatePulseResponse' }
        '400':
          description: Invalid query parameters
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
        '401':
          description: Authentication required
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
        '402':
          description: Monthly quota exceeded
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
        '403':
          description: |
            Tier-restricted (Starter requesting `geo_precision=dong`).
            Body includes `current_tier`, `required_tier`, `upgrade_url`.
          content: { application/json: { schema: { $ref: '#/components/schemas/TierRestrictedError' } } }
        '404':
          description: |
            No cohort matches. Layer 1 envelope still attached on _meta —
            ADR-015 acceptance criterion. Indicates honestly that no rows
            within disclosed coverage match the filters.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorWithEnvelope' } } }
        '429':
          description: Rate limit exceeded
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }

  /api/v1/real-estate/pulse/series:
    get:
      tags: [real-estate]
      summary: Multi-period pulse — N-period pulls in one call (cap 12)
      description: |
        Same row shape as `/pulse` but returns rows for multiple periods
        in a single call. Designed for institutional analysts pulling
        YoY/QoQ portfolio comparisons.

        Either `periods` (comma-separated list) or `from_period` +
        `to_period` (inclusive range) is required. Periods must be the
        same kind (all `YYYY-Qn` or all `YYYY-MM`); rolling windows
        (`last_3m`, etc.) are rejected.

        Quota cost = 1 call regardless of period count.

        Sparse-data: returns 200 if ANY period has data, 404 only if
        ALL requested periods are empty. Either way `_meta.missing_periods`
        lists periods with no rows so the caller can render gaps.
      parameters:
        - $ref: '#/components/parameters/SigunguCode'
        - $ref: '#/components/parameters/PropertyType'
        - $ref: '#/components/parameters/TransactionType'
        - in: query
          name: periods
          required: false
          schema:
            type: string
            example: '2025-Q1,2025-Q2,2025-Q3,2025-Q4,2026-Q1'
          description: 'Comma-separated calendar periods, max 12. Mutually exclusive with from_period/to_period.'
        - in: query
          name: from_period
          required: false
          schema:
            type: string
            example: '2025-Q1'
          description: 'Inclusive lower bound for range expansion. Pair with to_period; same kind on both sides.'
        - in: query
          name: to_period
          required: false
          schema:
            type: string
            example: '2026-Q1'
          description: 'Inclusive upper bound for range expansion. Expanded list must fit within 12-period cap.'
        - $ref: '#/components/parameters/GeoPrecision'
        - $ref: '#/components/parameters/Dong'
      responses:
        '200':
          description: |
            Cohort × period rows + envelope. `_meta` adds `periods_requested`,
            `periods_returned`, `missing_periods` (string arrays).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RealEstatePulseResponse' }
        '400':
          description: |
            Invalid query (missing periods + from/to, count > 12, mixed
            kinds, malformed period, range expansion overrun).
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
        '401':
          description: Authentication required
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
        '402':
          description: Monthly quota exceeded
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
        '403':
          description: Tier-restricted (`geo_precision=dong` requires Pro+)
          content: { application/json: { schema: { $ref: '#/components/schemas/TierRestrictedError' } } }
        '404':
          description: |
            All requested periods empty. Envelope still attached including
            missing_periods listing every requested period.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorWithEnvelope' } } }
        '429':
          description: Rate limit exceeded
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }

  /api/v1/real-estate/coverage:
    get:
      tags: [real-estate]
      summary: Discovery — for each cohort, which periods have data?
      description: |
        Discovery surface — answers "for cohort (sigungu, property_type,
        transaction_type), which periods have meaningful data?" before
        the analyst writes /pulse or /pulse/series queries.

        All filters are optional. Calling with no filters returns the
        top 200 cohorts globally, ordered by `total_samples` DESC. Same
        cohort threshold as /pulse (HAVING >= 2 samples per period).

        Empty result returns 200 (not 404) — "nothing available" is a
        valid discovery answer.
      parameters:
        - in: query
          name: sigungu_code
          required: false
          schema: { type: string, pattern: '^\d{5}$' }
        - $ref: '#/components/parameters/PropertyType'
        - $ref: '#/components/parameters/TransactionType'
        - $ref: '#/components/parameters/GeoPrecision'
        - $ref: '#/components/parameters/Dong'
        - in: query
          name: granularity
          required: false
          schema:
            type: string
            enum: [quarter, month]
            default: quarter
          description: Period bucket — `quarter` (YYYY-Qn) or `month` (YYYY-MM).
        - in: query
          name: limit
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 200
          description: Result row cap. Cohorts ordered by total_samples DESC.
      responses:
        '200':
          description: |
            Cohort discovery rows + envelope. `_meta` adds `granularity`
            and `row_cap` for caller introspection. Empty `data` array
            is valid (not 404).
          content:
            application/json:
              schema:
                type: object
                required: [data, _meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/RealEstateCoverageRow' }
                  _meta: { $ref: '#/components/schemas/LayerEnvelope' }
        '400':
          description: Invalid query (sigungu_code malformed, granularity outside enum, limit out of [1, 500]).
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
        '401':
          description: Authentication required
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
        '402':
          description: Monthly quota exceeded
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
        '403':
          description: Tier-restricted (`geo_precision=dong` requires Pro+)
          content: { application/json: { schema: { $ref: '#/components/schemas/TierRestrictedError' } } }
        '429':
          description: Rate limit exceeded
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }

  /api/v1/real-estate/indices:
    get:
      tags: [real-estate]
      summary: R-ONE macro indicators — vacancy / NOI yield / capital yield / total return.
      description: |
        Companion to `/api/v1/real-estate/pulse` (RTMS row-level distributions).
        Returns R-ONE-derived macro indicators per (sigungu, property_type,
        period) for the institutional CRE indicators analysts cite in
        quarterly reports — vacancy rate, NOI yield (annualized = cap rate),
        capital yield, total return.

        Data source: 한국부동산원 R-ONE (reb.or.kr/r-one), quarterly publish
        ~30 days post-quarter end. R-ONE 권역(district) units mapped to a
        representative `sigungu_code` for cross-reference with /pulse —
        boundaries don't align 1:1 (e.g. 도심 권역 → 11140).

        No `geo_precision=dong` parameter — R-ONE is regional aggregate by
        nature.

        Empty result returns 404 with the same `_meta` envelope so callers
        can audit boundary semantics regardless of data presence.
      parameters:
        - in: query
          name: sigungu_code
          required: true
          schema: { type: string, pattern: '^\d{5}$' }
          description: 5-digit 행정 시군구 코드 (e.g. `11680` = 강남구).
        - $ref: '#/components/parameters/PropertyType'
        - in: query
          name: metric_type
          required: false
          schema:
            type: string
            enum: [vacancy_rate, income_yield, capital_yield, total_return, rent_index, sale_price_index]
          description: |
            Filter by metric. Omit for all metrics in the cohort.
            `income_yield` is quarterly NOI yield — `_meta` per-row also
            exposes `annualized_cap_rate` = value × 4.
        - in: query
          name: period
          required: false
          schema:
            type: string
            pattern: '^(last_4q|last_8q|\d{4}-Q[1-4]|\d{4})$'
            default: last_4q
          description: |
            Period grammar — `last_4q` (rolling 4 quarters, default),
            `last_8q`, `YYYY-Q[1-4]` (specific quarter), `YYYY` (annual).
      responses:
        '200':
          description: |
            R-ONE indicator rows + envelope. `_meta.data_sources` includes
            `rone_official`. Each row carries `value_pct` (raw R-ONE %),
            and for `income_yield` rows the convenience `annualized_cap_rate`
            (= value_pct × 4).
          content:
            application/json:
              schema:
                type: object
                required: [data, _meta]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        sigungu_code:        { type: string }
                        sigungu:             { type: string }
                        property_type:       { type: string }
                        metric_type:         { type: string }
                        period:              { type: string }
                        period_end_date:     { type: string, format: date }
                        value_pct:           { type: number }
                        annualized_cap_rate: { type: number, nullable: true }
                  _meta: { $ref: '#/components/schemas/LayerEnvelope' }
            text/csv:
              schema: { type: string }
        '400':
          description: Invalid query (sigungu_code malformed, unknown metric_type/period).
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
        '401':
          description: Authentication required
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
        '402':
          description: Monthly quota exceeded
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
        '404':
          description: No R-ONE indices match these parameters within the disclosed coverage.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
        '429':
          description: Rate limit exceeded
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }

  /api/v1/real-estate/dimensions:
    get:
      tags: [real-estate]
      summary: Enumerable filter values for the real-estate vertical
      parameters:
        - $ref: '#/components/parameters/GeoPrecision'
      responses:
        '200':
          description: Available sigungus, gu_dongs, property_types, transaction_types
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RealEstateDimensionsResponse' }
        '401': { description: Authentication required }
        '403': { description: Tier-restricted (`geo_precision=dong` requires Pro+) }

  /api/v1/markets/pulse:
    get:
      tags: [marketplace]
      summary: Marketplace aggregate — workspace rentals/meetings
      description: |
        Distinct vertical from real-estate. Source is Syncle marketplace,
        privacy model is k-anonymity ≥ 3.
      parameters:
        - in: query
          name: region
          required: true
          schema: { type: string, example: '강남권' }
        - in: query
          name: category
          required: true
          schema: { type: string, example: 'office' }
        - in: query
          name: period
          required: true
          schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$', example: '2026-01' }
        - in: query
          name: product_type
          required: false
          schema: { type: string, enum: [rental, meeting] }
      responses:
        '200':
          description: Cohort aggregate
          content: { application/json: { schema: { type: object } } }

  /api/v1/markets/dimensions:
    get:
      tags: [marketplace]
      summary: Marketplace enumerable values
      responses:
        '200':
          description: regions / categories / periods
          content: { application/json: { schema: { type: object } } }

  /api/auth/me/usage:
    get:
      tags: [account]
      summary: Programmatic quota usage summary
      description: |
        Read-only usage view for the authenticated consumer's internal
        tooling (dashboards, alerting, monthly reports). Returns plan
        limits, current-period usage, trial state, subscription status.
        No Stripe internals exposed (no subscription_id / price_id /
        customer_id — those belong to the dashboard /billing surface).

        Self-calls do NOT count against monthly_request_quota. Polling
        recommended at >=30s intervals (counter updates per-minute).
      security: [{ sessionCookie: [] }]
      responses:
        '200':
          description: |
            { plan, limits: { monthly_request_quota, rate_limit_per_minute },
              current_period: { month, starts_at, resets_at, requests_used,
                requests_remaining, percent_used },
              trial: null | { is_active, trial_ends_at, days_remaining, founding_member },
              subscription: { status } }
        '401': { description: Not signed in }
        '404': { description: No active consumer for this account }

  /api/auth/me/keys:
    get:
      tags: [account]
      summary: List API keys
      security: [{ sessionCookie: [] }]
      responses:
        '200': { description: 'Array of {id, label, prefix, mode, scope, ip_allowlist, status, created_at}' }
        '401': { description: Not signed in }
    post:
      tags: [account]
      summary: Create new API key — plaintext returned ONCE
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label, mode, scope]
              properties:
                label:        { type: string, maxLength: 64 }
                mode:         { type: string, enum: [live, test] }
                scope:        { type: string, enum: [read_only, admin] }
                ip_allowlist: { type: array, items: { type: string }, description: 'CIDR strings' }
      responses:
        '200': { description: Newly issued key with plaintext (only here, ever) }
        '402': { description: Plan key-limit reached }

  /api/auth/me/keys/{id}:
    patch:
      tags: [account]
      summary: Update label or ip_allowlist
      security: [{ sessionCookie: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                label:        { type: string }
                ip_allowlist: { type: array, items: { type: string } }
      responses:
        '200': { description: Updated row }

  /api/auth/me/webhooks:
    get:
      tags: [account]
      summary: List webhook endpoints
      security: [{ sessionCookie: [] }]
      responses:
        '200': { description: Array of {id, url, events, status, ...} — secret never returned after creation }
    post:
      tags: [account]
      summary: Register webhook endpoint
      description: |
        Returns `secret_plaintext` once. Endpoint URL must be HTTPS.
        Veacon will POST signed events (X-Veacon-Signature) to this URL.
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:         { type: string, format: uri, pattern: '^https://' }
                description: { type: string }
                events:      { type: array, items: { type: string }, description: 'e.g. ["subscription.updated"] or ["*"]' }
      responses:
        '200': { description: 'Created — `secret_plaintext` returned once' }

  /api/auth/me/webhooks/{id}:
    delete:
      tags: [account]
      summary: Soft-revoke webhook endpoint
      security: [{ sessionCookie: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: '{ revoked: true }' }
        '404': { description: Not found or already revoked }

  /api/auth/me/webhooks/{id}/test:
    post:
      tags: [account]
      summary: Send synthetic test event to webhook endpoint
      description: |
        Stripe-style integration test. Sends a synthetic `webhook.test`
        event with the same HMAC scheme + envelope as production events,
        so a receiver that validates the test ping is guaranteed to
        validate real events. Endpoint must be `active` (revoked /
        disabled_after_failure → 409). Test bypasses the per-endpoint
        event filter.
      security: [{ sessionCookie: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: '{ test, event_id, event_type, delivery: { status, http_status, duration_ms, error } }' }
        '404': { description: Webhook not found }
        '409': { description: Endpoint not active }

  /api/auth/me/webhooks/{id}/deliveries:
    get:
      tags: [account]
      summary: List webhook delivery history (self-debug)
      description: |
        Paginated delivery log for a webhook endpoint. Buyer self-debug
        surface — diagnose "endpoint stopped working" / "Veacon is not
        sending events" / consecutive_failures trend without operator
        intervention. Self-calls do NOT count against monthly_request_quota.

        `payload` field is intentionally omitted from list responses to
        avoid accidental subscriber-data exposure on shared screens.
        Ownership-mismatch returns empty rows + zero summary (no leak).
      security: [{ sessionCookie: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: limit
          required: false
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
        - in: query
          name: offset
          required: false
          schema: { type: integer, default: 0, minimum: 0 }
      responses:
        '200':
          description: |
            { rows: [{id, event_type, event_id, status, http_status,
              duration_ms, error_message, attempt_count, created_at}],
              pagination: { limit, offset, returned },
              summary: { total_count, delivered_24h, failed_24h } }
        '400': { description: Invalid id / limit / offset }
        '401': { description: Not signed in }

  /api/auth/me/team:
    get:
      tags: [account]
      summary: List team members + seat limit
      security: [{ sessionCookie: [] }]
      responses:
        '200': { description: '{ members, seat_limit, plan }' }
    post:
      tags: [account]
      summary: Invite team member
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                role:  { type: string, enum: [member, admin], default: member }
      responses:
        '200': { description: Pending invite row }
        '402': { description: Seat limit exceeded for plan }
        '409': { description: Email already invited }

  /api/auth/me/team/{id}:
    delete:
      tags: [account]
      summary: Remove team member (soft revoke)
      security: [{ sessionCookie: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: '{ removed: true }' }

  /api/auth/me/invites:
    get:
      tags: [account]
      summary: List pending invitations addressed to my email
      security: [{ sessionCookie: [] }]
      responses:
        '200': { description: '{ invites: [{ id, consumer_id, company_name, role, invited_at }] }' }

  /api/auth/me/invites/{id}/accept:
    post:
      tags: [account]
      summary: Accept a pending team invitation
      security: [{ sessionCookie: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: '{ accepted: true }' }
        '404': { description: Invite not found / expired / addressed to different email }

  /api/status:
    get:
      tags: [real-estate]
      summary: Public uptime probe
      description: |
        Two health probes (real-estate + marketplace public pulse).
        Edge-cached `s-maxage=10, stale-while-revalidate=30` so external
        uptime monitors hitting the endpoint don't hammer the origin.
        Excluded from Sentry traces.
      responses:
        '200': { description: All probes up }
        '503': { description: One or more probes down }

components:
  parameters:
    SigunguCode:
      in: query
      name: sigungu_code
      required: true
      description: 5-digit 행정코드 (e.g., '11680' = 강남구)
      schema: { type: string, pattern: '^\d{5}$' }
    PropertyType:
      in: query
      name: property_type
      required: false
      schema:
        type: string
        enum: [office, retail, commercial_complex, mixed_use]
    TransactionType:
      in: query
      name: transaction_type
      required: false
      schema:
        type: string
        enum: [sale, chartered_lease, monthly_lease]
    Period:
      in: query
      name: period
      required: false
      description: |
        `last_3m` / `last_6m` (default) / `last_12m` rolling, plus
        `YYYY-MM` calendar month and `YYYY-Q1..Q4` calendar quarter.
      schema:
        type: string
        default: last_6m
        examples: [last_6m, '2026-03', '2026-Q1']
    GeoPrecision:
      in: query
      name: geo_precision
      required: false
      description: '`dong` requires Pro tier or higher.'
      schema:
        type: string
        enum: [sigungu, dong]
        default: sigungu
    Dong:
      in: query
      name: dong
      required: false
      description: Used only when `geo_precision=dong`.
      schema: { type: string, example: '역삼동' }

  headers:
    XRequestID:
      description: |
        Correlation ID surfaced on every response. Format
        `req_<22-char-base64url>` when generated server-side, or the
        caller-provided ID when a valid `X-Request-ID` header was sent
        on the request (Stripe / Plaid distributed-tracing pattern).
        Mirrored inside `_meta.request_id` and on error envelopes
        (`error.request_id`) so callers can cite it without inspecting
        headers.
      schema: { type: string, example: 'req_aB3-xYz1234567890abcd' }

  schemas:
    LayerEnvelope:
      type: object
      description: |
        ADR-015 Layer 1-3 envelope. Rides every `/api/v1/real-estate/*`
        response — including 404 — to surface the structural data
        boundary at the protocol level.
      properties:
        service: { type: string, enum: [veacon] }
        api_version: { type: string, enum: [v1] }
        vertical: { type: string, enum: [real_estate] }
        data_sources: { type: array, items: { type: string } }
        coverage_estimate: { type: [number, 'null'] }
        coverage_estimate_method: { type: string }
        coverage_note: { type: string }
        lease_data_status: { type: string }
        lease_data_note: { type: string }
        known_limitations: { type: array, items: { type: string } }
        disclosure_url: { type: string, format: uri }
        confidence: { type: [string, 'null'], enum: [low, medium, high, null] }
        source_mix: { type: object, additionalProperties: { type: integer } }
        tier: { type: string }
        generated_at: { type: string, format: date-time }
        rate_limit:
          type: object
          properties:
            limit_per_min: { type: integer }
            remaining: { type: [integer, 'null'] }

    RealEstatePulseRow:
      type: object
      properties:
        sigungu_code:    { type: string }
        sigungu:         { type: string }
        gu:              { type: [string, 'null'] }
        dong:            { type: [string, 'null'] }
        property_type:   { type: string }
        transaction_type:{ type: string }
        period:          { type: string }
        sample_count:    { type: integer }
        avg_price:       { type: integer }
        p25_price:       { type: integer }
        median_price:    { type: integer }
        p75_price:       { type: integer }
        avg_monthly_rent:    { type: [integer, 'null'] }
        p25_monthly_rent:    { type: [integer, 'null'] }
        median_monthly_rent: { type: [integer, 'null'] }
        p75_monthly_rent:    { type: [integer, 'null'] }
        avg_area_m2:     { type: string }
        confidence:      { type: string, enum: [low, medium, high] }
        source_mix:      { type: object, additionalProperties: { type: integer } }
        source_means:    { type: object, additionalProperties: { type: integer } }
        confidence_factors:
          type: object
          properties:
            distinct_sources: { type: integer }
            relative_spread:  { type: number }
            sample_count:     { type: integer }
            ladder_reason:    { type: string }

    RealEstatePulseResponse:
      type: object
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/RealEstatePulseRow' }
        _meta: { $ref: '#/components/schemas/LayerEnvelope' }

    RealEstateCoverageRow:
      type: object
      description: |
        Per-cohort discovery summary returned by /coverage. Threshold
        matches /pulse — only periods with `>= 2` samples count.
      properties:
        sigungu_code:               { type: string, example: '11680' }
        sigungu:                    { type: string, example: '강남구' }
        gu:                         { type: [string, 'null'] }
        dong:                       { type: [string, 'null'] }
        property_type:              { type: string, example: office }
        transaction_type:           { type: string, example: sale }
        earliest_period:            { type: string, example: '2025-Q2' }
        latest_period:              { type: string, example: '2026-Q1' }
        period_count:               { type: integer, example: 4 }
        periods_with_data:
          type: array
          items: { type: string }
          description: Sorted list. Drop into `/pulse/series?periods=...` directly.
        total_samples:              { type: integer, example: 18 }
        samples_per_period_median:  { type: integer, example: 4 }
        data_sources:
          type: array
          items: { type: string }
          description: Distinct `data_source_type` values across all periods.

    RealEstateDimensionsResponse:
      type: object
      properties:
        data:
          type: object
          properties:
            sigungus:
              type: array
              items:
                type: object
                properties:
                  value:        { type: string }
                  code:         { type: string }
                  sample_count: { type: integer }
            gu_dongs:
              type: array
              items:
                type: object
                properties:
                  value:        { type: string }
                  code:         { type: string }
                  sample_count: { type: integer }
            property_types:
              type: array
              items:
                type: object
                properties:
                  value:        { type: string }
                  sample_count: { type: integer }
            transaction_types:
              type: array
              items:
                type: object
                properties:
                  value:        { type: string }
                  sample_count: { type: integer }
        _meta: { $ref: '#/components/schemas/LayerEnvelope' }

    ErrorResponse:
      type: object
      properties:
        error:
          type: object
          properties:
            code:    { type: string, example: INVALID_PARAMS }
            message: { type: string }
        _meta:
          type: object
          properties:
            service:     { type: string, example: veacon }
            api_version: { type: string, example: v1 }

    ErrorWithEnvelope:
      type: object
      properties:
        error:
          type: object
          properties:
            code:    { type: string, example: NOT_FOUND }
            message: { type: string }
            hint:    { type: string }
        _meta: { $ref: '#/components/schemas/LayerEnvelope' }

    TierRestrictedError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:           { type: string, enum: [TIER_RESTRICTED] }
            message:        { type: string }
            current_tier:   { type: string }
            required_tier:  { type: string, example: pro }
            upgrade_url:    { type: string, format: uri }

  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        B2B consumer authentication. Issued via /dashboard/api-keys or
        founding-member onboarding script. Scope + IP allowlist enforced
        per-key (ADR-013).
    sessionCookie:
      type: apiKey
      in: cookie
      name: veacon_session
      description: |
        Syncle SSO session cookie. Used by /api/auth/me/* dashboard
        endpoints (X-API-Key not accepted there — these are user-scoped,
        not consumer-scoped).
