{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://landfeed.pl/contracts/envelope.json",
  "title": "landfeed response envelope v1",
  "description": "Every /v1 response body, success or failure, REST or MCP (structured result), is one Envelope. Ambiguity is not a failure: an ambiguous input returns HTTP 200, status 'ambiguous', and candidates. A value is never a guess: where the state does not publish it, coverage is 'not_covered' and value is null. Billing is in the body: what was charged, what was refunded, and the rule that decided it. Owner: docs/landfeed/README.md step 1; rationale: context/research/cenogram-teardown-2026-10-12.md sections 8 and 9.",
  "type": "object",
  "required": ["status", "query", "result", "candidates", "sources", "billing", "error", "meta"],
  "additionalProperties": false,
  "properties": {
    "status": { "$ref": "#/$defs/Status" },
    "query": { "$ref": "#/$defs/QueryEcho" },
    "result": {
      "description": "Endpoint-specific payload (openapi.yaml). Null when status is not 'ok'.",
      "type": ["object", "array", "null"]
    },
    "candidates": {
      "description": "Non-empty when status is 'ambiguous' (and always on /v1/resolve). Ordered by confidence, highest first. An agent re-calls with id=<candidate.id> or shows the list to the user; it never picks one silently.",
      "type": "array",
      "items": { "$ref": "#/$defs/Candidate" }
    },
    "needs_obreb": {
      "description": "True when the parcel number exists in several obręby of the resolved gmina and nothing in the input picks one.",
      "type": "boolean",
      "default": false
    },
    "corpus_coverage": { "$ref": "#/$defs/CorpusCoverage" },
    "sources": {
      "description": "Every source that contributed to result, deduplicated. Each layer answer also names its own source.",
      "type": "array",
      "items": { "$ref": "#/$defs/SourceRef" }
    },
    "links": {
      "description": "Follow-up calls an agent may plan, each with its cost in requests, e.g. {\"check\": {\"href\": \"/v1/check?id=...\", \"cost\": 1}}.",
      "type": "object",
      "additionalProperties": { "$ref": "#/$defs/Link" }
    },
    "billing": { "$ref": "#/$defs/Billing" },
    "error": {
      "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/Error" }]
    },
    "page": { "$ref": "#/$defs/Page" },
    "meta": { "$ref": "#/$defs/Meta" }
  },
  "$defs": {
    "Status": {
      "description": "ok: result present (HTTP 200). ambiguous: result null, candidates non-empty (200, charged). not_found: nothing matches the input (404, refunded). not_covered: the input resolved, but no loaded partition or source covers it (200, result null, refunded). error: see error.code.",
      "type": "string",
      "enum": ["ok", "ambiguous", "not_found", "not_covered", "error"]
    },
    "Coverage": {
      "description": "Four-state coverage of one layer at one place. covered: the source covers this place and holds a record for it; value is that record. covered_no_data: the source covers this place and holds no record for it; value is the layer's defined empty answer (false, 0, [], 'none'), never null: an affirmative negative, charged. not_covered: the source does not cover this place, or the state does not publish this machine-readably; value is null, the agent must not infer anything, refunded when it is the answer asked for. not_computed: we hold the source but have not computed this layer for this place yet (e.g. a partition still loading); value is null, refunded. Every layer answer carries a plain-language note whatever its state.",
      "type": "string",
      "enum": ["covered", "covered_no_data", "not_covered", "not_computed"]
    },
    "QueryInput": {
      "description": "Exactly one of q, id, point (lat and lng together count as point). Every read endpoint and every tool accepts all three.",
      "type": "object",
      "oneOf": [
        { "required": ["q"], "not": { "anyOf": [{ "required": ["id"] }, { "required": ["point"] }, { "required": ["lat"] }, { "required": ["lng"] }] } },
        { "required": ["id"], "not": { "anyOf": [{ "required": ["q"] }, { "required": ["point"] }, { "required": ["lat"] }, { "required": ["lng"] }] } },
        { "required": ["point"], "not": { "anyOf": [{ "required": ["q"] }, { "required": ["id"] }, { "required": ["lat"] }, { "required": ["lng"] }] } },
        { "required": ["lat", "lng"], "not": { "anyOf": [{ "required": ["q"] }, { "required": ["id"] }, { "required": ["point"] }] } }
      ],
      "properties": {
        "q": {
          "description": "Free text as a human typed it: 'pogorzelec dziłaka 284/5', 'Marcinowice 221', 'Lesnowola 25/18', 'obręb 9 / O18', an address, or a full id in either form.",
          "type": "string", "minLength": 1, "maxLength": 300
        },
        "id": { "$ref": "#/$defs/ParcelIdInput" },
        "point": {
          "description": "WGS84 'lat,lng' in decimal degrees, e.g. '52.2297,21.0122'. Resolves to the parcel containing the point.",
          "type": "string",
          "pattern": "^-?[0-9]{1,2}(\\.[0-9]+)?, ?-?[0-9]{1,3}(\\.[0-9]+)?$"
        },
        "lat": { "type": "number", "minimum": 49.0, "maximum": 55.0 },
        "lng": { "type": "number", "minimum": 14.0, "maximum": 24.5 }
      }
    },
    "ParcelIdInput": {
      "description": "EGiB id_dzialki in slash or dash form: WWPPGG_R.OOOO[.AR_n].NUMBER, e.g. '146501_1.0101.12/3' or '146501_1.0101.12-3'. The dash form is normalised to the slash form; every id the API returns is the canonical slash form.",
      "type": "string",
      "pattern": "^[0-9]{6}_[0-9]\\.[0-9A-Za-z]{4}(\\.AR_[0-9]+)?\\.[0-9A-Za-z]+([/-][0-9A-Za-z]+)*$"
    },
    "ParcelId": {
      "description": "Canonical EGiB id_dzialki (slash form), the primary key. TERYT gmina (7 chars) is WWPPGGR derived from it.",
      "type": "string",
      "pattern": "^[0-9]{6}_[0-9]\\.[0-9A-Za-z]{4}(\\.AR_[0-9]+)?\\.[0-9A-Za-z]+(/[0-9A-Za-z]+)*$"
    },
    "QueryEcho": {
      "type": "object",
      "required": ["input", "resolved"],
      "additionalProperties": false,
      "properties": {
        "input": { "$ref": "#/$defs/QueryInput" },
        "resolved": {
          "description": "How the input became a parcel or gmina. Null when status is ambiguous or not_found.",
          "oneOf": [
            { "type": "null" },
            {
              "type": "object",
              "required": ["from", "method", "id", "confidence"],
              "additionalProperties": false,
              "properties": {
                "from": { "type": "string", "enum": ["q", "id", "point"] },
                "method": {
                  "type": "string",
                  "enum": ["exact_id", "normalised_id", "text_parcel", "text_parcel_fuzzy", "text_address", "text_gmina", "point_in_polygon"]
                },
                "id": { "type": "string" },
                "confidence": { "type": "number", "minimum": 0, "maximum": 1 },
                "note": { "description": "What the resolver assumed, e.g. 'Lesnowola read as Lesznowola (fuzzy, confidence lowered)'.", "type": ["string", "null"] }
              }
            }
          ]
        }
      }
    },
    "Candidate": {
      "type": "object",
      "required": ["id", "kind", "label", "confidence"],
      "additionalProperties": false,
      "properties": {
        "id": { "description": "Canonical ParcelId for kind=parcel, 7-char TERYT for kind=gmina, obręb id for kind=obreb.", "type": "string" },
        "kind": { "type": "string", "enum": ["parcel", "obreb", "gmina", "address"] },
        "label": { "description": "Human-readable: 'dz. 284/5, obręb Pogorzelec, gm. Pogorzela (pow. gostyński)'.", "type": "string" },
        "voivodeship": { "type": "string", "pattern": "^[0-9]{2}$" },
        "gmina_teryt": { "type": "string", "pattern": "^[0-9]{7}$" },
        "gmina_name": { "type": "string" },
        "powiat_name": { "type": "string" },
        "obreb_id": { "type": "string" },
        "obreb_name": { "type": "string" },
        "number": { "type": "string" },
        "area_m2": { "type": ["number", "null"] },
        "centroid": { "$ref": "#/$defs/LonLat" },
        "confidence": { "type": "number", "minimum": 0, "maximum": 1 },
        "match_reason": { "description": "Which input tokens matched what, e.g. 'locality fuzzy Lesnowola→Lesznowola (distance 1), number exact'.", "type": "string" },
        "held": { "description": "False when the candidate exists in the national register but its partition is not loaded yet.", "type": "boolean", "default": true }
      }
    },
    "CorpusCoverage": {
      "description": "How much of the relevant register we hold, so an agent can tell 'not found' from 'not loaded'. Returned on resolve and on every not_found or not_covered.",
      "type": "object",
      "required": ["scope", "source_parcels", "held_parcels", "held_pct", "as_of", "note"],
      "additionalProperties": false,
      "properties": {
        "scope": { "description": "'national', a voivodeship code, or a 7-char gmina TERYT.", "type": "string" },
        "source_parcels": { "type": ["integer", "null"] },
        "held_parcels": { "type": "integer" },
        "held_pct": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
        "as_of": { "type": ["string", "null"], "format": "date" },
        "note": { "type": "string" }
      }
    },
    "LonLat": {
      "description": "GeoJSON order: [lng, lat], WGS84.",
      "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2
    },
    "SourceRef": {
      "description": "One row of /v1/sources as cited by a layer. as_of is the upstream data date (last successful refresh of the source), not the request time.",
      "type": "object",
      "required": ["id", "title", "attribution", "as_of"],
      "additionalProperties": false,
      "properties": {
        "id": { "description": "source_status.source_id", "type": "string" },
        "title": { "type": "string" },
        "publisher": { "type": "string" },
        "attribution": { "type": "string" },
        "licence": { "type": "string" },
        "url": { "type": ["string", "null"] },
        "as_of": { "type": ["string", "null"], "format": "date" },
        "fallback": { "description": "True when this source answered because the primary had no record (our plan ogólny sweep when Rejestr Urbanistyczny has none).", "type": "boolean", "default": false }
      }
    },
    "LayerAnswer": {
      "description": "Common frame of every layer answer. Layer-specific fields go in value (openapi.yaml *Value schemas).",
      "type": "object",
      "required": ["layer", "coverage", "value", "note", "source", "as_of", "layer_version"],
      "additionalProperties": false,
      "properties": {
        "layer": { "$ref": "#/$defs/LayerKey" },
        "coverage": { "$ref": "#/$defs/Coverage" },
        "value": { "description": "null iff coverage is not_covered or not_computed.", "type": ["object", "null"] },
        "note": {
          "description": "One plain-language sentence an agent can quote, in every state: what the value means, or why there is none. Never 'safe', never 'no plan' without saying which plan and which source.",
          "type": "string", "minLength": 1
        },
        "source": { "$ref": "#/$defs/SourceRef" },
        "as_of": { "type": ["string", "null"], "format": "date" },
        "layer_version": { "description": "Precompute run id for precomputed layers; 'live' for request-time layers.", "type": "string" },
        "hash": { "description": "sha256 hex of coverage + ':' + canonical(value) (parcel_record.sql header). Null for request-time layers.", "type": ["string", "null"] },
        "basis": { "description": "Statute or rule behind a derived answer, e.g. 'u.p.z.p. art. 59 ust. 3'.", "type": ["string", "null"] }
      },
      "allOf": [
        {
          "if": { "properties": { "coverage": { "enum": ["not_covered", "not_computed"] } } },
          "then": { "properties": { "value": { "type": "null" } } },
          "else": { "properties": { "value": { "type": "object" } } }
        }
      ]
    },
    "LayerKey": {
      "description": "planning is the one-request planning answer: plan ogólny, MPZP, WZ and the gmina's planning status together.",
      "type": "string",
      "enum": ["parcel", "admin", "address", "buildings", "planning", "pog", "wz", "mpzp", "flood", "landslide", "protected", "road_access", "permits", "prices", "soil"]
    },
    "PlanStatus": {
      "description": "Status of a plan ogólny or MPZP: draft (projekt, not adopted), adopted (uchwalony, not yet in force), in_force (obowiązuje), none (no such plan recorded by the source for this place). effective_from accompanies adopted and in_force.",
      "type": "string",
      "enum": ["draft", "adopted", "in_force", "none"]
    },
    "Billing": {
      "description": "What this request cost. charged: requests charged after refunds. refunded: requests metered then returned. rule: why. Charged: answers, informative negatives (covered_no_data) and ambiguous candidates. Free: invalid input (400), unauthenticated (401), not modified (304), free operations. Refunded: other 4xx, every 5xx, status not_covered, and a single-layer answer that is not_covered or not_computed. Batch: counts are per item.",
      "type": "object",
      "required": ["cost", "charged", "refunded", "rule"],
      "additionalProperties": false,
      "properties": {
        "cost": { "description": "The operation's x-request-cost (per item for batch).", "type": "integer", "minimum": 0 },
        "charged": { "type": "integer", "minimum": 0 },
        "refunded": { "type": "integer", "minimum": 0 },
        "rule": {
          "type": "string",
          "enum": ["charged", "free_operation", "free_invalid_input", "free_unauthorized", "free_not_modified", "refunded_client_error", "refunded_server_error", "refunded_not_covered", "refunded_not_computed", "batch_mixed"]
        }
      }
    },
    "Link": {
      "type": "object",
      "required": ["href", "cost"],
      "additionalProperties": false,
      "properties": {
        "href": { "type": "string" },
        "cost": { "type": "integer", "minimum": 0 }
      }
    },
    "Error": {
      "description": "Every error tells the agent what to do next: hint always, an example id when the input was a parcel, a docs link always, signup_url on 401.",
      "type": "object",
      "required": ["code", "message", "hint", "docs"],
      "additionalProperties": false,
      "properties": {
        "code": {
          "description": "invalid_input 400 (none or several of q|id|point, malformed id or point; free). not_found 404 (refunded). ambiguous 200 (mirrors status; candidates set). not_covered 200 (input resolved outside loaded data; refunded). unauthorized 401 (missing or wrong X-Api-Key; free). rate_limited 429. source_unavailable 503 (a request-time source failed; refunded). internal 500 (refunded).",
          "type": "string",
          "enum": ["invalid_input", "not_found", "ambiguous", "not_covered", "unauthorized", "rate_limited", "source_unavailable", "internal"]
        },
        "message": { "description": "One sentence an agent can relay.", "type": "string" },
        "hint": { "description": "What to call next, e.g. 'Pass exactly one of q, id or point' or 'Add the obręb name or number'.", "type": "string", "minLength": 1 },
        "example_id": { "description": "A valid id in the canonical form, e.g. '146501_1.0101.12/3'.", "type": ["string", "null"] },
        "field": { "type": ["string", "null"] },
        "docs": { "type": "string" },
        "signup_url": { "type": ["string", "null"] }
      }
    },
    "Page": {
      "description": "Cursor pagination for list results (search, events, sources).",
      "type": "object",
      "required": ["limit", "has_more", "next_cursor"],
      "additionalProperties": false,
      "properties": {
        "limit": { "type": "integer", "minimum": 1, "maximum": 1000 },
        "has_more": { "type": "boolean" },
        "next_cursor": { "type": ["string", "null"] }
      }
    },
    "Meta": {
      "type": "object",
      "required": ["request_id", "api_version", "generated_at", "calls"],
      "additionalProperties": false,
      "properties": {
        "request_id": { "type": "string" },
        "api_version": { "type": "string", "const": "v1" },
        "generated_at": { "type": "string", "format": "date-time" },
        "calls": { "description": "Requests this answer took. Always 1 for a single read; the bench asserts it.", "type": "integer", "minimum": 1 },
        "etag": { "description": "Same value as the ETag header on GET by id.", "type": ["string", "null"] },
        "excluded": {
          "description": "Fields withheld by policy, never served: KW numbers, owner and holder data, investor names, address-level transaction prices.",
          "type": "array", "items": { "type": "string" }
        },
        "disclaimer": { "type": ["string", "null"] }
      }
    }
  }
}
