{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://openpaintformat.org/schemas/common/v1/common.schema.json",
  "title": "Open Paint Format Common Document Envelope v1 (draft)",
  "description": "Shared envelope embedded by every Open Paint Format document (recipe, palette, inventory, project, technique). Defines stable identity, versioning, attribution, licensing, provenance, links (revision/fork lineage), translations, namespaced extensions, and integrity. Concrete specifications compose `$defs/envelopeCore` via allOf and close the object with `unevaluatedProperties: false`. DRAFT: not frozen; see specs/common/v1/README.md.",
  "type": "object",
  "allOf": [{ "$ref": "#/$defs/envelopeCore" }],
  "unevaluatedProperties": false,
  "$defs": {
    "envelopeCore": {
      "$comment": "Reusable, OPEN envelope fragment. Does not close the object, so concrete specs can add their own members via allOf and close with unevaluatedProperties.",
      "type": "object",
      "required": ["spec", "specVersion", "id", "revision", "title"],
      "properties": {
        "$schema": {
          "type": "string",
          "format": "uri-reference",
          "description": "Optional pointer to the schema this document claims to conform to."
        },
        "spec": {
          "type": "string",
          "pattern": "^[a-z][a-z0-9-]*$",
          "description": "Specification name, e.g. 'common', 'recipe', 'palette'."
        },
        "specVersion": {
          "type": "string",
          "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$",
          "description": "Semantic version (SemVer 2.0.0) of the specification the document targets."
        },
        "id": {
          "type": "string",
          "format": "uri",
          "description": "Stable, globally unique document identifier as an absolute URI (e.g. urn:uuid:... or https://...). MUST NOT require a central BrushCodex registry."
        },
        "revision": {
          "type": "string",
          "minLength": 1,
          "description": "Opaque revision identifier for this exact document state (UUID, hash, or monotonic token)."
        },
        "title": {
          "type": "string",
          "minLength": 1,
          "description": "Human-readable title."
        },
        "description": {
          "type": "string",
          "description": "Optional human-readable description."
        },
        "createdAt": {
          "type": "string",
          "format": "date-time",
          "description": "RFC 3339 creation timestamp, when known."
        },
        "updatedAt": {
          "type": "string",
          "format": "date-time",
          "description": "RFC 3339 last-modified timestamp, when known. SHOULD be >= createdAt."
        },
        "language": {
          "type": "string",
          "pattern": "^(und|[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*)$",
          "description": "Primary content language as a BCP 47 tag, or 'und' if undetermined."
        },
        "authors": {
          "type": "array",
          "items": { "$ref": "#/$defs/agent" },
          "description": "Primary authors of the content."
        },
        "contributors": {
          "type": "array",
          "items": { "$ref": "#/$defs/agent" },
          "description": "Additional contributors."
        },
        "attribution": {
          "type": "string",
          "minLength": 1,
          "description": "Human-readable credit for the document as a whole: who or what it is based on, and any acknowledgement the author owes. Free text because credit obligations are prose, not structure. It does NOT state or grant a licence (see license), does NOT make anyone an author (see authors), and a consumer MUST preserve it verbatim and MUST NOT parse it into agents. An empty string is invalid — omit the member instead."
        },
        "license": { "$ref": "#/$defs/license" },
        "links": {
          "type": "object",
          "additionalProperties": false,
          "description": "Cross-document links expressing provenance, canonical location, and revision/fork lineage.",
          "properties": {
            "source": { "type": "string", "format": "uri" },
            "canonical": { "type": "string", "format": "uri" },
            "predecessor": { "type": "string", "format": "uri" },
            "successor": { "type": "string", "format": "uri" },
            "forkOrigin": { "type": "string", "format": "uri" }
          }
        },
        "provenance": {
          "type": "array",
          "items": { "$ref": "#/$defs/provenanceEntry" },
          "description": "Provenance entries describing where the document (or values within it) came from."
        },
        "tags": {
          "type": "array",
          "items": { "type": "string", "minLength": 1 },
          "uniqueItems": true
        },
        "translations": {
          "type": "array",
          "items": { "$ref": "#/$defs/translation" }
        },
        "extensions": {
          "type": "object",
          "description": "Namespaced extension data. Keys MUST be namespaced (reverse-DNS or colon-qualified). Unknown extensions are preserved on round trip when preservation is claimed.",
          "propertyNames": {
            "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*[.:][A-Za-z0-9][A-Za-z0-9._:-]*$"
          },
          "additionalProperties": true
        },
        "integrity": { "$ref": "#/$defs/integrity" }
      }
    },
    "agent": {
      "type": "object",
      "additionalProperties": false,
      "required": ["name"],
      "properties": {
        "name": { "type": "string", "minLength": 1 },
        "id": { "type": "string", "format": "uri" },
        "role": { "type": "string" },
        "url": { "type": "string", "format": "uri" }
      }
    },
    "license": {
      "type": "object",
      "additionalProperties": false,
      "description": "Content license. At least one of spdxId or name MUST be present. Unknown values are never invented.",
      "properties": {
        "spdxId": {
          "type": "string",
          "minLength": 1,
          "description": "SPDX license identifier (e.g. CC-BY-4.0, Apache-2.0, LicenseRef-... , or the SPDX 'NONE'/'NOASSERTION' tokens)."
        },
        "name": { "type": "string", "minLength": 1 },
        "url": { "type": "string", "format": "uri" },
        "notice": { "type": "string" }
      },
      "anyOf": [{ "required": ["spdxId"] }, { "required": ["name"] }]
    },
    "provenanceEntry": {
      "type": "object",
      "additionalProperties": false,
      "required": ["sourceType"],
      "properties": {
        "sourceType": {
          "type": "string",
          "enum": [
            "manufacturer_digital_swatch",
            "physical_measurement",
            "community_estimate",
            "digital_approximation",
            "photographed_sample",
            "synthetic_test_fixture",
            "unknown"
          ],
          "description": "Controlled source class. Color-bearing measurements MUST NOT be labelled as physical_measurement unless produced by instrument measurement of a physical sample. digital_approximation is a screen-display approximation of unstated or derived origin (e.g. a third-party colour list) and carries no manufacturer or measurement authority."
        },
        "reviewStatus": {
          "type": "string",
          "enum": ["pending_review", "approved", "rejected", "deprecated"],
          "description": "Review state under the project's policy. 'approved' does not imply manufacturer endorsement."
        },
        "confidence": {
          "type": "string",
          "enum": ["high", "medium", "low", "unknown"]
        },
        "sourceName": { "type": "string" },
        "sourceUrl": { "type": "string", "format": "uri" },
        "contributor": { "type": "string" },
        "retrievedAt": { "type": "string", "format": "date-time" },
        "license": { "$ref": "#/$defs/license" },
        "transformationVersion": {
          "type": "string",
          "description": "Version of the importer/transformer that produced this entry."
        },
        "method": {
          "type": "string",
          "description": "Methodology detail (e.g. measurement geometry, illuminant) for physical measurements."
        },
        "derivedFrom": {
          "type": "array",
          "items": { "type": "string", "format": "uri" },
          "description": "URIs of documents/values this entry was derived from."
        },
        "note": { "type": "string" }
      }
    },
    "colorValue": {
      "type": "object",
      "additionalProperties": false,
      "required": ["hex"],
      "description": "An sRGB color value. Its meaning (measured, digital swatch, estimate) is declared by the owning item's provenance; never presented as a physical measurement unless provenance says so.",
      "properties": {
        "hex": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }
      }
    },
    "paintRef": {
      "type": "object",
      "additionalProperties": false,
      "description": "Canonical paint reference shared by every Open Paint Format spec (Recipe, Palette, Inventory, Project). At least one of manufacturer or name MUST be present; nothing else is required, so a literal paint with no catalogue id or color stays valid (the literal fallback for paints with no shared catalogue entry).",
      "properties": {
        "ref": {
          "type": "string",
          "minLength": 1,
          "description": "OPTIONAL document-local anchor other members can point to (e.g. a Recipe step or mixture). Where a spec anchors to it (Recipe paints[]) it MUST be unique within that array (a semantic rule; JSON Schema cannot express it). Specs without intra-document anchoring simply omit it."
        },
        "manufacturer": { "type": "string", "minLength": 1 },
        "range": { "type": "string", "minLength": 1 },
        "name": { "type": "string", "minLength": 1 },
        "code": {
          "type": "string",
          "minLength": 1,
          "description": "The manufacturer's printed product code / item number on the bottle (e.g. a Vallejo item number such as 70.950). Distinct from catalogueId (an external stable identifier) and from any internal database id, which MUST NOT appear here."
        },
        "kind": {
          "type": "string",
          "enum": ["paint", "medium", "thinner", "additive", "varnish"],
          "description": "Classifies any component referenced the way paints are (in paints[]/mix[]), bottled or not, by its function in the mixture. Absence means an ordinary pigment paint. A component that does not determine the resulting colour (a medium, thinner, varnish, or additive) is marked here so the color engine skips it in matching, and a renderer SHOULD NOT draw a colour swatch for a non-'paint' kind even when color is present. A household diluent such as water is 'additive'; 'thinner' is for a product sold and identified as a thinner. A colour-bearing component such as a dry pigment stirred into a carrier is 'paint', because it determines the resulting colour. This is function, not purchasability: a consumer MUST NOT infer that an 'additive' is an acquirable product. Ordinary tools are resources, never paintRefs; a consumable material is a paintRef when it joins a mixture at an authored ratio and a resource when used in the process."
        },
        "chemistry": {
          "type": "string",
          "enum": ["acrylic", "enamel", "oil", "lacquer", "other"],
          "description": "Binder/solvent family — the substitution-safety axis (an enamel MUST NOT be silently substituted for an acrylic layer). These four named values are the binder-family subset of Technique's paintClass vocabulary, plus 'other'; chemistry is distinct from paintClass, which is a broader product-type advisory."
        },
        "catalogueId": {
          "type": "string",
          "minLength": 1,
          "description": "OPTIONAL external stable identifier (opaque token or URI). Never required."
        },
        "color": { "$ref": "#/$defs/colorValue" },
        "provenance": {
          "type": "array",
          "items": { "$ref": "#/$defs/provenanceEntry" }
        },
        "note": { "type": "string" }
      },
      "anyOf": [{ "required": ["manufacturer"] }, { "required": ["name"] }]
    },
    "translation": {
      "type": "object",
      "additionalProperties": false,
      "required": ["language"],
      "properties": {
        "language": {
          "type": "string",
          "pattern": "^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$"
        },
        "title": { "type": "string" },
        "description": { "type": "string" }
      }
    },
    "integrity": {
      "type": "object",
      "additionalProperties": false,
      "required": ["algorithm", "value"],
      "properties": {
        "algorithm": { "type": "string", "enum": ["sha-256", "sha-512"] },
        "value": { "type": "string", "pattern": "^[0-9a-f]+$" }
      }
    },
    "resource": {
      "type": "object",
      "additionalProperties": false,
      "required": ["name"],
      "description": "A tool or non-paint material needed to reproduce a workflow (brush, airbrush, wet palette, sponge, hobby knife, UV lamp, gloves, PVA glue, masking putty, static grass, pigments, IPA, sandpaper, resin, and mediums/varnish where NOT represented as a paint), consumable materials being resources when used in the process. Shared by Recipe.resources, Technique.tools, and Project.toolsUsed. Carries no manufacturer or catalogue identity. A reusable tool MUST NOT be modelled as a paintRef under any usage; a consumable material that joins a mixture at an authored ratio is referenced as a paintRef for that recipe instead (Recipe section 6a carries the worked examples).",
      "properties": {
        "name": { "type": "string", "minLength": 1 },
        "kind": {
          "type": "string",
          "enum": ["tool", "material"],
          "description": "A reusable tool vs a consumable non-paint material."
        },
        "optional": {
          "type": "boolean",
          "description": "True when the item is optional rather than required. Absence means required."
        },
        "specification": {
          "type": "string",
          "description": "Optional human-readable specification, e.g. 'Size 2 round', '0.3 mm nozzle', '120-grit'. Never an invented precise value."
        },
        "quantity": {
          "type": "string",
          "description": "Optional human-readable quantity, e.g. 'a few drops', '2 sheets'. Never an invented precise measurement."
        },
        "note": { "type": "string" }
      }
    },
    "documentRef": {
      "type": "object",
      "additionalProperties": false,
      "required": ["id"],
      "description": "A soft reference to another Open Paint Format document by its stable id URI. The referenced document need not be resolvable by the consumer; an unresolved reference is not an error. Shared by Project (recipeRefs/paletteRefs) and Recipe (techniqueRefs).",
      "properties": {
        "id": {
          "type": "string",
          "format": "uri",
          "description": "The referenced document's stable id URI (its envelope id)."
        },
        "title": { "type": "string" }
      }
    },
    "role": {
      "type": "string",
      "description": "Coarse, closed grouping/ordering vocabulary shared by Recipe steps and Palette entries. Named specific techniques (pin wash, zenithal, wet blending) use a free-text field (e.g. Recipe step.technique), never this enum, so the vocabulary stays small and stable across the freeze.",
      "enum": [
        "primer",
        "basecoat",
        "undercoat",
        "shadow",
        "midtone",
        "layer",
        "highlight",
        "edge_highlight",
        "spot_highlight",
        "wash",
        "glaze",
        "drybrush",
        "weathering",
        "metallic",
        "texture",
        "decal",
        "varnish",
        "other"
      ]
    },
    "evidenceClass": {
      "type": "string",
      "description": "How a paint substitution was established, shared by Recipe alternative.type and Project substitution.type. Each value is an evidence class kept distinct so a mathematical match never masquerades as a verified practical one; there is deliberately no catch-all.",
      "enum": [
        "authored",
        "manufacturer_published",
        "mathematical",
        "community_tested",
        "verified_practical"
      ]
    },
    "target": {
      "type": "object",
      "additionalProperties": false,
      "required": ["description"],
      "description": "The subject a document is for. Shared by Recipe and Palette so the two cannot drift.",
      "properties": {
        "kind": {
          "type": "string",
          "enum": ["miniature", "model", "material", "surface", "terrain", "generic"]
        },
        "description": { "type": "string", "minLength": 1 },
        "scale": {
          "type": "object",
          "additionalProperties": false,
          "required": ["system", "value"],
          "description": "Optional scale. Deliberately small: only the two observed measurement systems. Physical height and subject form (e.g. bust) are intentionally NOT collapsed into scale — form belongs to kind/description, and a physical-height system may be added in a future minor version only if evidenced.",
          "properties": {
            "system": {
              "type": "string",
              "enum": ["nominal_mm", "ratio"],
              "description": "nominal_mm = wargame/heroic nominal size (value like '28' or '32'); ratio = scale-model ratio (value like '1:35')."
            },
            "value": { "type": "string", "minLength": 1 }
          }
        },
        "substrate": {
          "type": "string",
          "enum": ["resin", "plastic", "metal", "mdf", "foam", "pla", "other"],
          "description": "Physical composition of the subject (drives priming and solvent safety, e.g. a solvent primer will melt bare foam). Distinct from kind (a category) and from the kind value 'material'."
        },
        "identity": {
          "$ref": "#/$defs/subjectIdentity",
          "description": "OPTIONAL identity of the discrete Painted Subject this target denotes, when one is known. A refinement of the applicability statement, never a replacement for description: kind/description/scale/substrate keep saying what the document applies to. Absent for a broad or class-level target, and MUST NOT be fabricated."
        }
      }
    },
    "subjectIdentity": {
      "type": "object",
      "additionalProperties": false,
      "required": ["authority", "designation"],
      "description": "The identity of the discrete, authority-named Painted Subject a target denotes — a sculpt, model, build, or an authority-named unit/set. Literal-first, exactly like paintRef: authority and designation are the guaranteed offline floor and are REQUIRED whenever this object is present, so an identifier never travels without something a human can read. This is NOT Source Product identity: it is never a commercial box, bundle, SKU, GTIN, retailer listing, storefront URL, or internal database row, and never the document itself. It is also distinct from Project subjects[], which are document-local execution records.",
      "properties": {
        "authority": {
          "type": "string",
          "minLength": 1,
          "description": "REQUIRED. The party whose designation this identity is anchored to — the manufacturer, studio, or sculptor. Never a retailer or a storefront."
        },
        "designation": {
          "type": "string",
          "minLength": 1,
          "description": "REQUIRED. The subject's name as that authority gives it. Not a description of what is painted — target.description keeps that job."
        },
        "qualifier": {
          "type": "string",
          "minLength": 1,
          "description": "OPTIONAL. Only the paint-relevant distinction needed to remove ambiguity between subjects an authority names alike (e.g. an original sculpt versus a remaster whose geometry changes the painting order). Not a second description."
        },
        "authorityId": {
          "type": "string",
          "minLength": 1,
          "description": "OPTIONAL identifier the authority itself assigns to the subject, such as a sculpt or part code. Reader-facing, like paintRef.code. NOT a SKU, GTIN, or product number."
        },
        "subjectId": {
          "type": "string",
          "minLength": 1,
          "description": "OPTIONAL stable, opaque, EXTERNAL identifier for the Painted Subject — a progressive enhancement over the literal floor, exactly as paintRef.catalogueId is for a paint. Compared by whole-string equality; a consumer MUST NOT parse meaning out of its segments. Resolution is best-effort and an unresolved value is NOT an error: validity never depends on a registry or a network. RECOMMENDED form 'brushcodex:subject:<authority>/<line>/<subject>' — a convention, not a constraint. MUST NOT be fabricated."
        }
      }
    },
    "mediaRef": {
      "type": "object",
      "additionalProperties": false,
      "required": ["url"],
      "description": "A linked media item with its own rights metadata. Shared by Recipe (document media and step media) and Project (results) so the two cannot drift. Media is referenced, never embedded; the author is responsible for the rights of what they link, and a consumer MUST NOT auto-dereference the URL without user intent.",
      "properties": {
        "id": {
          "type": "string",
          "minLength": 1,
          "description": "OPTIONAL document-local anchor that MUST be unique within the owning array (recipe media[], a step's media[], project results[]; semantic rule media-id-unique — JSON Schema cannot express it), so a mediaCitation can point at this item. Not a global identifier and never a database id."
        },
        "url": { "type": "string", "format": "uri" },
        "kind": { "type": "string", "enum": ["image", "video", "other"] },
        "relation": {
          "type": "string",
          "enum": ["source", "result", "reference"],
          "description": "OPTIONAL relationship to the document. 'source' = a work the document was derived from (for example a tutorial, class, article, demonstration, or prior record); 'result' = a record of the outcome; 'reference' = material consulted but not derived from. Absent means unstated — never infer one.",
          "$comment": "Deliberately three evidenced values. 'source' carries an attribution obligation: it does NOT grant any licence to the linked work."
        },
        "caption": { "type": "string" },
        "creator": {
          "$ref": "#/$defs/agent",
          "description": "OPTIONAL creator of the linked media, when known — the attribution target for a 'source' relation. Distinct from the document's own authors: deriving a workflow from another work does not make that work's creator an author of the document. MUST NOT be invented."
        },
        "license": {
          "$ref": "#/$defs/license",
          "description": "OPTIONAL licence of the linked media itself, which is independent of the document's licence. Publicly reachable media is NOT thereby openly licensed; absent means unknown."
        },
        "rightsNote": { "type": "string" }
      }
    },
    "mediaCitation": {
      "type": "object",
      "additionalProperties": false,
      "required": ["startSeconds"],
      "description": "A citation of a moment or range in a time-based source the document links (for example the passage of a video, class recording, or demonstration from which a step was derived). Time is carried as seconds so any consumer can seek deterministically without parsing human timecode text.",
      "properties": {
        "media": {
          "type": "string",
          "minLength": 1,
          "description": "Anchor into the owning document's media[].id. OPTIONAL only when the document has exactly one media entry with relation 'source', which the citation then targets."
        },
        "startSeconds": {
          "type": "number",
          "minimum": 0,
          "description": "Offset from the start of the media item, in seconds. Fractional values are allowed."
        },
        "endSeconds": {
          "type": "number",
          "exclusiveMinimum": 0,
          "description": "OPTIONAL end of the cited range, in seconds. When present it MUST be greater than startSeconds (a semantic rule the schema cannot express). Absent means the citation is a single point, not a zero-length range."
        },
        "label": {
          "type": "string",
          "minLength": 1,
          "description": "OPTIONAL citation as the author wrote it (e.g. '1:00-1:35'), preserved verbatim so a round trip through a human-timecode format is loss-free. Renderers MAY show it; they MUST NOT parse it in preference to the seconds members."
        }
      }
    }
  }
}
