{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://palimplace.com/spec/lp-v-v0.schema.json",
  "title": "Linked Places + Viewpoint (LP-V) record, v0",
  "description": "An LP-V attestation: ONE JSON object that is simultaneously a valid GeoJSON Feature (RFC 7946), a STAC Item (1.0.0), and JSON-LD. The modality-neutral core (place relation, when, provenance, three-axis confidence, status, rights, stable URI) carries every attestation; per-type facets (the photograph facet `viewpoint`, an OGC MF-JSON pose trajectory) hang off it. STAC owns properties.datetime (the coarse index, DERIVED from `when`); Linked Places / GeoJSON-T own the top-level, uncertainty-bearing `when`; every LP-V semantic field is a top-level GeoJSON foreign member (RFC 7946 6.1), so nothing collides with STAC's properties. The seam — never let the prior masquerade as data — is enforced by the conditional rules in `allOf`: evidence (trace/testimony) must NAME a non-blank source; inference (derived/synthetic) must SHOW its working as a PROV-O chain that names what it used; a record carrying a PROV chain cannot wear an evidence label; a viewOf must carry a viewpoint. additionalProperties is closed on every LP-V object so a typo cannot silently become data. Deep GeoJSON-geometry, full SPDX, full STAC, and cross-field invariants (quaternion unit-norm, viewpoint datetimes within `when`) are checked in CI on top of this schema.",
  "type": "object",
  "required": [
    "type",
    "stac_version",
    "id",
    "geometry",
    "bbox",
    "properties",
    "links",
    "assets",
    "when",
    "status",
    "placeRelation",
    "provenance",
    "confidence",
    "rights"
  ],
  "additionalProperties": false,
  "patternProperties": {
    "^[a-z][a-z0-9_]*:": {}
  },
  "properties": {
    "@context": {
      "description": "JSON-LD context. Array form: the Linked Places context (resolves when/certainty/relations) followed by the versioned LP-V context (status, viewpoint, provenance, three-axis confidence, rights, the typed place relation, declared units and reference frames, and the STAC/CIDOC/Dublin Core crosswalks).",
      "oneOf": [
        { "type": "string", "format": "uri" },
        { "type": "array", "minItems": 1, "items": { "type": ["string", "object"] } }
      ]
    },
    "type": { "const": "Feature" },
    "stac_version": { "const": "1.0.0" },
    "stac_extensions": { "type": "array", "items": { "type": "string", "format": "uri" } },
    "id": { "$ref": "#/$defs/nonBlankString" },
    "collection": { "$ref": "#/$defs/nonBlankString" },
    "geometry": { "$ref": "#/$defs/geometry" },
    "bbox": { "$ref": "#/$defs/bbox" },
    "properties": { "$ref": "#/$defs/stacProperties" },
    "links": { "type": "array", "items": { "$ref": "#/$defs/link" } },
    "assets": { "type": "object", "additionalProperties": { "$ref": "#/$defs/asset" } },
    "when": { "$ref": "#/$defs/when" },
    "status": { "$ref": "#/$defs/status" },
    "placeRelation": { "$ref": "#/$defs/placeRelation" },
    "viewpoint": { "$ref": "#/$defs/viewpoint" },
    "media": { "$ref": "#/$defs/media" },
    "provenance": { "$ref": "#/$defs/provenance" },
    "confidence": { "$ref": "#/$defs/confidence" },
    "rights": { "$ref": "#/$defs/rights" }
  },
  "allOf": [
    {
      "$comment": "SEAM rule 1 - evidence must name a (non-blank) source. trace and testimony require provenance.source or provenance.archive; the nonBlankString $ref on those fields makes an empty or whitespace value fail.",
      "if": {
        "required": ["status"],
        "properties": { "status": { "enum": ["trace", "testimony"] } }
      },
      "then": {
        "properties": {
          "provenance": {
            "anyOf": [{ "required": ["source"] }, { "required": ["archive"] }]
          }
        }
      }
    },
    {
      "$comment": "SEAM rule 2 - inference must show its working. derived and synthetic require a PROV-O chain that names what it used (provChain enforces used/wasDerivedFrom are non-empty).",
      "if": {
        "required": ["status"],
        "properties": { "status": { "enum": ["derived", "synthetic"] } }
      },
      "then": {
        "required": ["provenance"],
        "properties": { "provenance": { "required": ["prov"] } }
      }
    },
    {
      "$comment": "SEAM rule 3 (bidirectional) - a record carrying a PROV-O generation chain is inference; it cannot wear an evidence label. If provenance.prov is present, status must be derived or synthetic.",
      "if": {
        "required": ["provenance"],
        "properties": { "provenance": { "required": ["prov"] } }
      },
      "then": {
        "properties": { "status": { "enum": ["derived", "synthetic"] } }
      }
    },
    {
      "$comment": "SEAM rule 4 - a viewOf is hung at its frustum, so it must carry the viewpoint facet; and the viewpoint facet only belongs on a viewOf.",
      "if": {
        "required": ["placeRelation"],
        "properties": { "placeRelation": { "const": "viewOf" } }
      },
      "then": { "required": ["viewpoint"] },
      "else": { "not": { "required": ["viewpoint"] } }
    },
    {
      "$comment": "SEAM rule 5 - an optimistic open licence may never sit over a true In-Copyright (or unevaluated) status. If the statement is in the In-Copyright / unclear family, license must be absent.",
      "if": {
        "required": ["rights"],
        "properties": {
          "rights": {
            "required": ["statement"],
            "properties": {
              "statement": {
                "enum": [
                  "http://rightsstatements.org/vocab/InC/1.0/",
                  "http://rightsstatements.org/vocab/InC-OW-EU/1.0/",
                  "http://rightsstatements.org/vocab/InC-EDU/1.0/",
                  "http://rightsstatements.org/vocab/InC-NC/1.0/",
                  "http://rightsstatements.org/vocab/InC-RUU/1.0/",
                  "http://rightsstatements.org/vocab/CNE/1.0/",
                  "http://rightsstatements.org/vocab/UND/1.0/"
                ]
              }
            }
          }
        }
      },
      "then": {
        "properties": { "rights": { "not": { "required": ["license"] } } }
      }
    },
    {
      "$comment": "STAC datetime rule - an open/interval datetime (null) requires start_datetime and end_datetime, both DERIVED from `when` (CI enforces the derivation). A precise instant sets datetime and omits the range.",
      "if": {
        "required": ["properties"],
        "properties": {
          "properties": {
            "required": ["datetime"],
            "properties": { "datetime": { "type": "null" } }
          }
        }
      },
      "then": {
        "properties": {
          "properties": { "required": ["start_datetime", "end_datetime"] }
        }
      }
    }
  ],
  "$defs": {
    "nonBlankString": {
      "description": "A string that is not empty and not only whitespace - so a 'required' field cannot be satisfied by naming nothing.",
      "type": "string",
      "minLength": 1,
      "pattern": "\\S"
    },
    "geometry": {
      "description": "A GeoJSON geometry (RFC 7946) - the place anchor. A photograph vantage is a Point; an event a Polygon; a journey a LineString. Coordinate-depth validation is left to a dedicated GeoJSON validator in CI.",
      "type": "object",
      "required": ["type"],
      "oneOf": [
        {
          "properties": {
            "type": { "const": "Point" },
            "coordinates": { "type": "array", "minItems": 2, "items": { "type": "number" } }
          },
          "required": ["type", "coordinates"]
        },
        {
          "properties": {
            "type": { "const": "MultiPoint" },
            "coordinates": { "type": "array", "items": { "type": "array", "minItems": 2, "items": { "type": "number" } } }
          },
          "required": ["type", "coordinates"]
        },
        {
          "properties": {
            "type": { "const": "LineString" },
            "coordinates": { "type": "array", "minItems": 2, "items": { "type": "array", "minItems": 2, "items": { "type": "number" } } }
          },
          "required": ["type", "coordinates"]
        },
        {
          "properties": {
            "type": { "const": "MultiLineString" },
            "coordinates": { "type": "array" }
          },
          "required": ["type", "coordinates"]
        },
        {
          "properties": {
            "type": { "const": "Polygon" },
            "coordinates": { "type": "array" }
          },
          "required": ["type", "coordinates"]
        },
        {
          "properties": {
            "type": { "const": "MultiPolygon" },
            "coordinates": { "type": "array" }
          },
          "required": ["type", "coordinates"]
        },
        {
          "properties": {
            "type": { "const": "GeometryCollection" },
            "geometries": { "type": "array", "items": { "$ref": "#/$defs/geometry" } }
          },
          "required": ["type", "geometries"]
        }
      ]
    },
    "bbox": {
      "description": "STAC-required spatial bounds when geometry is non-null. 2D ([west, south, east, north]) or 3D (with min/max elevation).",
      "type": "array",
      "items": { "type": "number" },
      "oneOf": [
        { "minItems": 4, "maxItems": 4 },
        { "minItems": 6, "maxItems": 6 }
      ]
    },
    "stacProperties": {
      "description": "STAC properties - the coarse, searchable index ONLY. LP-V semantics never live here; they are top-level members. `datetime` is DERIVED from `when` and may be null for an interval, in which case start_/end_datetime are required (see allOf). Left open (no additionalProperties:false) because STAC common metadata and extensions legitimately add fields here.",
      "type": "object",
      "required": ["datetime"],
      "properties": {
        "datetime": { "type": ["string", "null"], "format": "date-time" },
        "start_datetime": { "type": "string", "format": "date-time" },
        "end_datetime": { "type": "string", "format": "date-time" },
        "created": {
          "type": "string",
          "format": "date-time",
          "description": "STAC record-time: when the RECORD was made. Distinct from `when` (referent-time: the moment depicted)."
        },
        "updated": { "type": "string", "format": "date-time" },
        "title": { "type": "string" },
        "description": { "type": "string" }
      }
    },
    "link": {
      "type": "object",
      "additionalProperties": false,
      "required": ["rel", "href"],
      "properties": {
        "rel": { "$ref": "#/$defs/nonBlankString" },
        "href": { "$ref": "#/$defs/nonBlankString" },
        "type": { "type": "string" },
        "title": { "type": "string" }
      }
    },
    "asset": {
      "description": "A STAC asset - the crawler-facing pointer to where the media lives. `type` is the media type of what `href` actually returns (a landing page is text/html, not image/jpeg). CI fills `href` by-reference (source host) unless the `rights` block clears bundling, in which case it may point at a bundled local copy.",
      "type": "object",
      "additionalProperties": false,
      "required": ["href"],
      "properties": {
        "href": { "$ref": "#/$defs/nonBlankString" },
        "type": { "type": "string" },
        "title": { "type": "string" },
        "description": { "type": "string" },
        "roles": { "type": "array", "items": { "type": "string" } }
      }
    },
    "when": {
      "description": "Referent-time: the moment the record refers to, uncertainty-bearing, per GeoJSON-T / Linked Places. The authoritative temporal truth; properties.datetime is derived from it. A sibling of type/geometry/properties.",
      "type": "object",
      "additionalProperties": false,
      "required": ["timespans"],
      "properties": {
        "timespans": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/timespan" } },
        "certainty": { "enum": ["certain", "less-certain", "uncertain"] },
        "label": { "type": "string" },
        "duration": { "type": "string" }
      }
    },
    "timespan": {
      "type": "object",
      "additionalProperties": false,
      "anyOf": [{ "required": ["start"] }, { "required": ["end"] }],
      "properties": {
        "start": { "$ref": "#/$defs/timeBound" },
        "end": { "$ref": "#/$defs/timeBound" }
      }
    },
    "timeBound": {
      "description": "A GeoJSON-T time bound. `in` for a known value; `earliest`/`latest` to bracket uncertainty. ISO 8601, reduced precision allowed (e.g. 1993, 1993-07).",
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "in": { "type": "string" },
        "earliest": { "type": "string" },
        "latest": { "type": "string" }
      },
      "anyOf": [{ "required": ["in"] }, { "required": ["earliest"] }, { "required": ["latest"] }]
    },
    "status": {
      "description": "The record's relationship to its referent. trace = direct physical record (photograph, scan); testimony = primary account (recollection, caption); derived = computed (solved pose, transcription); synthetic = generated. trace+testimony are EVIDENCE; derived+synthetic are INFERENCE and must carry a PROV-O chain.",
      "enum": ["trace", "testimony", "derived", "synthetic"]
    },
    "placeRelation": {
      "description": "How the attestation relates to the place, and therefore how a renderer hangs it: a photograph is a viewOf (hung at its frustum); a recollection is about (by the feature it concerns); an event occurredAt (across a region and span); a document describes (a readable plane).",
      "enum": ["viewOf", "about", "occurredAt", "describes"]
    },
    "viewpoint": {
      "description": "Photograph facet - an OGC MF-JSON MovingFeature (a pose trajectory). A still is the degenerate single-sample case; a clip is the full case. Orientation quaternions are canonical, component order (x, y, z, w) scalar-last, unit norm (CI-enforced |q|=1), mapping the glTF camera basis (camera looks down local -Z, +Y up, +X right) into a local East-North-Up frame (+X East, +Y North, +Z Up) at the position. Euler is a derived human-only view, never canonical.",
      "type": "object",
      "additionalProperties": false,
      "required": ["type", "temporalGeometry"],
      "properties": {
        "type": { "const": "MovingFeature" },
        "temporalGeometry": {
          "description": "MF-JSON MovingPoint: position over time. CI checks len(coordinates) == len(datetimes) and that each datetime falls within `when`.",
          "type": "object",
          "additionalProperties": false,
          "required": ["type", "datetimes", "coordinates"],
          "properties": {
            "type": { "const": "MovingPoint" },
            "datetimes": { "type": "array", "minItems": 1, "items": { "type": "string", "format": "date-time" } },
            "coordinates": {
              "description": "[lng, lat, eye-height m]; EPSG:4326 position with height in metres (QUDT unit:M). One sample for a still.",
              "type": "array",
              "minItems": 1,
              "items": { "type": "array", "minItems": 3, "maxItems": 3, "items": { "type": "number" } }
            },
            "interpolation": { "enum": ["Discrete", "Step", "Linear"] }
          }
        },
        "temporalProperties": {
          "description": "MF-JSON temporal properties: orientation and intrinsics over time, so a still generalises to film without a schema change.",
          "type": "array",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": ["datetimes"],
            "properties": {
              "datetimes": { "type": "array", "items": { "type": "string", "format": "date-time" } },
              "orientation": {
                "type": "object",
                "additionalProperties": false,
                "required": ["values"],
                "properties": {
                  "values": {
                    "description": "Unit quaternion(s), component order (x, y, z, w), scalar w last. Unit norm is CI-enforced.",
                    "type": "array",
                    "items": { "type": "array", "minItems": 4, "maxItems": 4, "items": { "type": "number", "minimum": -1, "maximum": 1 } }
                  },
                  "interpolation": { "enum": ["Discrete", "Step", "Linear"] }
                }
              },
              "hFovDeg": {
                "description": "Horizontal field of view in degrees (QUDT unit:DEG). A clip may zoom, so this is a temporal property.",
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "values": { "type": "array", "items": { "type": "number" } },
                  "interpolation": { "enum": ["Discrete", "Step", "Linear"] }
                }
              }
            }
          }
        },
        "poseConfidence": { "type": "number", "minimum": 0, "maximum": 1 },
        "poseMethod": {
          "description": "How the pose was obtained. `manual` - a human aimed the frustum; `pnp` - Perspective-n-Point from 2D-3D correspondences; `sfm` - structure-from-motion. `ai-estimated` - an AI reasoned the vantage from the photograph's own description and the place's geography (a prior, NOT a survey); such a pose carries a low-to-moderate poseConfidence and a poseNote stating its basis, so the renderer marks it estimated and it can never pass for a measured fact. The photograph itself stays evidence (its `status`); only the pose is inferred.",
          "enum": ["manual", "pnp", "sfm", "ai-estimated"]
        },
        "poseNote": {
          "description": "Human-readable basis for the pose - e.g. an AI's reasoning ('the caption says \"from East Berlin\", so the camera stood east of the gate looking west') or a registrant's note. Carries the working behind an estimated or registered vantage so the inference is auditable in the viewer, distinct from the record-level PROV-O chain the seam reserves for derived/synthetic status.",
          "type": "string"
        }
      }
    },
    "media": {
      "description": "Reference to source-hosted media - the index points, the host owns. The canonical pointer; STAC `assets` carry the crawler-facing href(s). `format` is the media type of what `uri` returns (text/html for a landing page). CI decides bundle-vs-reference from the `rights` block, not from here.",
      "type": "object",
      "additionalProperties": false,
      "required": ["uri", "type"],
      "properties": {
        "uri": { "type": "string", "format": "uri" },
        "type": { "enum": ["image", "video", "audio", "document", "model"] },
        "iiifManifest": { "type": "string", "format": "uri" },
        "iiifImageApi": { "type": "string", "format": "uri", "description": "A IIIF Image API service base URL, where the platform exposes one (the bytes, tiled), even without a Presentation manifest." },
        "format": { "type": "string", "description": "IANA media type of what `uri` returns." }
      }
    },
    "provenance": {
      "description": "Lineage. SEAM: trace/testimony MUST name a non-blank `source` or `archive`; derived/synthetic MUST carry `prov` (a PROV-O chain), and any record with `prov` is inference. Enforced in allOf + the nonBlankString refs.",
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "source": { "$ref": "#/$defs/nonBlankString", "description": "Origin of the attestation - photographer, author, witness." },
        "archive": { "$ref": "#/$defs/nonBlankString" },
        "contributor": { "$ref": "#/$defs/nonBlankString", "description": "Stable identity - signed-commit author, ORCID, github:handle." },
        "accession": { "$ref": "#/$defs/nonBlankString" },
        "prov": { "$ref": "#/$defs/provChain" }
      }
    },
    "provChain": {
      "description": "Minimal PROV-O chain for a derived or synthetic record. The record is a prov:Entity generated by a prov:Activity (the pose solve or generative process) that used the source(s) and was associated with a software or human agent. SEAM: `used` and `wasDerivedFrom` must name something non-empty - inference cannot show its working by pointing at nothing.",
      "type": "object",
      "additionalProperties": false,
      "anyOf": [{ "required": ["wasGeneratedBy"] }, { "required": ["wasDerivedFrom"] }],
      "properties": {
        "wasGeneratedBy": {
          "type": "object",
          "additionalProperties": false,
          "required": ["used"],
          "properties": {
            "used": {
              "description": "The entity/entities the activity consumed (e.g. the source photograph, the 3D base, clicked correspondences). A non-empty URI, an object, or a non-empty array of either.",
              "oneOf": [
                { "$ref": "#/$defs/nonBlankString" },
                { "type": "object", "minProperties": 1 },
                { "type": "array", "minItems": 1, "items": { "oneOf": [{ "$ref": "#/$defs/nonBlankString" }, { "type": "object", "minProperties": 1 }] } }
              ]
            },
            "wasAssociatedWith": { "oneOf": [{ "$ref": "#/$defs/nonBlankString" }, { "type": "object", "minProperties": 1 }] },
            "startedAtTime": { "type": "string", "format": "date-time" },
            "endedAtTime": { "type": "string", "format": "date-time" }
          }
        },
        "wasDerivedFrom": {
          "oneOf": [
            { "$ref": "#/$defs/nonBlankString" },
            { "type": "object", "minProperties": 1 },
            { "type": "array", "minItems": 1, "items": { "oneOf": [{ "$ref": "#/$defs/nonBlankString" }, { "type": "object", "minProperties": 1 }] } }
          ]
        },
        "wasAttributedTo": { "oneOf": [{ "$ref": "#/$defs/nonBlankString" }, { "type": "object", "minProperties": 1 }] }
      }
    },
    "confidence": {
      "description": "Three independent axes, never collapsed: a sharply-dated photo of uncertain location differs from a vaguely-dated one nailed to a known wall. For testimony the axes are read as witness / reliability / corroboration rather than toward pose.",
      "type": "object",
      "additionalProperties": false,
      "required": ["temporal", "spatial", "source"],
      "properties": {
        "temporal": { "type": "number", "minimum": 0, "maximum": 1 },
        "spatial": { "type": "number", "minimum": 0, "maximum": 1 },
        "source": { "type": "number", "minimum": 0, "maximum": 1 }
      }
    },
    "rights": {
      "description": "First-class rights - one block, three jobs: it drives search, the bundle-vs-reference decision in CI, and AI-use. `statement` is the cultural-heritage copyright STATUS (rightsstatements.org); `license` is the actual LICENCE when one applies (SPDX). At least one is required. The two are deliberately separate: an archive's optimistic licence label never overrides a true In-Copyright status (allOf rule 5 forbids a licence under an In-Copyright statement). An AI/TDM reservation is the TDMRep simple form: tdmReservation 1 (reserved) with an optional tdmPolicy URL.",
      "type": "object",
      "additionalProperties": false,
      "anyOf": [{ "required": ["statement"] }, { "required": ["license"] }],
      "properties": {
        "statement": { "$ref": "#/$defs/rightsStatement" },
        "license": {
          "type": "string",
          "description": "SPDX licence id where an actual licence applies (e.g. CC0-1.0, CC-BY-4.0, CC-BY-SA-2.0, Apache-2.0). Validated against the SPDX list in CI. Absent when no clean licence applies (e.g. In Copyright)."
        },
        "holder": { "type": "string" },
        "credit": { "type": "string", "description": "Required attribution string, e.g. 'Image courtesy of Manchester Libraries'." },
        "tdmReservation": {
          "description": "TDMRep simple form: 1 = text-and-data-mining (incl. AI training) is RESERVED; 0 = not reserved. Honoured at the record level.",
          "enum": [0, 1]
        },
        "tdmPolicy": { "type": "string", "format": "uri", "description": "Optional URL of a TDMRep/ODRL policy detailing TDM terms." },
        "permits": {
          "description": "ODRL action URIs the rights permit, e.g. http://www.w3.org/ns/odrl/2/distribute .",
          "type": "array",
          "items": { "type": "string", "format": "uri" }
        },
        "prohibits": {
          "description": "ODRL action URIs the rights prohibit.",
          "type": "array",
          "items": { "type": "string", "format": "uri" }
        }
      }
    },
    "rightsStatement": {
      "description": "A rightsstatements.org controlled-vocabulary URI. The http scheme is canonical - it is an identifier, do not normalise to https. True public domain is asserted in `license` (CC0-1.0 or the CC Public Domain Mark), not here.",
      "type": "string",
      "enum": [
        "http://rightsstatements.org/vocab/InC/1.0/",
        "http://rightsstatements.org/vocab/InC-OW-EU/1.0/",
        "http://rightsstatements.org/vocab/InC-EDU/1.0/",
        "http://rightsstatements.org/vocab/InC-NC/1.0/",
        "http://rightsstatements.org/vocab/InC-RUU/1.0/",
        "http://rightsstatements.org/vocab/NoC-CR/1.0/",
        "http://rightsstatements.org/vocab/NoC-NC/1.0/",
        "http://rightsstatements.org/vocab/NoC-OKLR/1.0/",
        "http://rightsstatements.org/vocab/NoC-US/1.0/",
        "http://rightsstatements.org/vocab/CNE/1.0/",
        "http://rightsstatements.org/vocab/UND/1.0/",
        "http://rightsstatements.org/vocab/NKC/1.0/"
      ]
    }
  }
}
