{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://pipelinexlab.com/contracts/component-rich-surface-declaration.v1.schema.json",
  "title": "PipelineXLab Component rich surface declaration v1",
  "description": "The closed `component-rich-surface-declaration@1` record one Component ships beside its V2 declaration, at content/surfaces/{componentKey}/{surfaceRole}/surface.json. It is a separate contract with its own version, not a wider Component grammar: the V2 packaged declaration is closed and unchanged, and a Component that ships no such record is byte-for-byte the Component it always was. One Component may ship up to four surfaces, each under its own role: the reserved `editor`, which the host files under its Edit tab, and package roles it names itself, each of which the node modal draws as its own tab in the left rail. The record's own canonical RFC-8785 bytes are the whole-asset closure -- they name the document and every asset with that asset's exact digest -- and the digest of those bytes is the single content address that whole surface is served under.",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "$schema",
    "contractVersion",
    "componentKey",
    "surfaceRole",
    "bridgeContract",
    "documentPath",
    "documentDigest",
    "assets"
  ],
  "properties": {
    "$schema": {
      "const": "https://pipelinexlab.com/contracts/component-rich-surface-declaration.v1.schema.json"
    },
    "contractVersion": {
      "const": "component-rich-surface-declaration@1"
    },
    "componentKey": {
      "description": "The Component this surface belongs to. It must equal the key the entry path spells, so one path spells one Component and one (Component, role) pair spells one surface.",
      "type": "string",
      "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
    },
    "surfaceRole": {
      "description": "Which tab of the node modal this surface is. `editor` is reserved: it is the role every surface published before package tabs existed carries, and the host files it under its own Edit tab. Any other value is a publisher key the package chose, drawn in the rail under its declared label. The five names the host draws itself -- settings, inputs, output, code, ai -- are refused, so a package can never put its document where a reader expects the product's.",
      "type": "string",
      "maxLength": 64,
      "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
      "not": {
        "enum": [
          "settings",
          "inputs",
          "output",
          "code",
          "ai"
        ]
      }
    },
    "bridgeContract": {
      "description": "The versioned host/document bridge the served document is booted through. The host transfers one MessagePort with its bootstrap message and reads exactly one surface-ready reply on it. A surface is told the version it declared, never the host's preference, so a document written against version 1 still recognises its own boot after version 2 was admitted.",
      "enum": [
        "component-rich-surface-bridge@1",
        "component-rich-surface-bridge@2"
      ]
    },
    "documentPath": {
      "description": "Fixed, because the served URL is fixed: every surface opens at /component-surfaces/{closureAddress}/index.html, so a document named anything else would name a file no viewer requests.",
      "const": "index.html"
    },
    "documentDigest": {
      "$ref": "#/$defs/contentDigest"
    },
    "assets": {
      "description": "The document's siblings. A surface is flat: there is no subdirectory to descend into, so every asset resolves as a sibling of the document under the same immutable closure address. The member is required and may be empty; an omitted list is refused rather than read as a surface with no assets.",
      "type": "array",
      "maxItems": 32,
      "uniqueItems": true,
      "items": {
        "$ref": "#/$defs/asset"
      }
    },
    "label": {
      "description": "The tab name the rail draws. Required for a package role and refused for `editor`, whose tab the host names itself. It is one trimmed, control-free line; the rail is 172px wide, so a longer label is refused at admission rather than truncated into a different word.",
      "type": "string",
      "minLength": 1,
      "maxLength": 40,
      "pattern": "^\\S(?:.*\\S)?$"
    },
    "icon": {
      "description": "The rail glyph, from the host's own governed publisher icon inventory. Optional for a package role and refused for `editor`. An icon the host has no glyph for is refused: a presentation hint nothing can draw is not a hint. The list is the design system's own governed publisher icon inventory, generated into pxflow-core as GOVERNED_PUBLISHER_ICON_KEYS; the two are held equal by a gate, so this schema cannot admit a glyph the host does not draw.",
      "enum": [
        "bar-chart-3",
        "boxes",
        "brackets",
        "brain",
        "code-2",
        "database",
        "eye",
        "file-box",
        "file-input",
        "file-output",
        "file-spreadsheet",
        "file-text",
        "flask-conical",
        "gauge",
        "globe",
        "image",
        "list",
        "package",
        "route",
        "search",
        "settings",
        "sigma",
        "square-function",
        "table-2",
        "upload",
        "workflow",
        "file-pdf"
      ]
    },
    "receives": {
      "description": "What this surface is handed when it boots. `node-context` is the bounded Node projection every surface has always received. `run-result` adds the values of the current Run, for a surface whose purpose is to draw them. A surface receives what it declared and nothing else, so an omitted or empty list means `node-context` alone.",
      "type": "array",
      "maxItems": 2,
      "uniqueItems": true,
      "items": {
        "enum": [
          "node-context",
          "run-result"
        ]
      }
    }
  },
  "$defs": {
    "contentDigest": {
      "description": "The exact sha256 of the bytes at this path. The publisher content-addresses its own closure and the install measures the container bytes against the claim, so custody is never taken on a declared digest alone.",
      "type": "string",
      "pattern": "^sha256:[0-9a-f]{64}$"
    },
    "asset": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "path",
        "digest"
      ],
      "properties": {
        "path": {
          "description": "One root-relative sibling path: a single segment beginning with an ASCII alphanumeric, continuing in [A-Za-z0-9._-], carrying one admitted extension. A separator, a traversal, an absolute path, a percent escape, a query, a fragment, credentials, a scheme or an authority each fail this pattern, and `surface.json` is reserved -- the record that names the closure is not a member of it.",
          "type": "string",
          "maxLength": 128,
          "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*\\.(css|js|json|svg|png|webp|woff2)$",
          "not": {
            "const": "surface.json"
          }
        },
        "digest": {
          "$ref": "#/$defs/contentDigest"
        }
      }
    }
  },
  "allOf": [
    {
      "$comment": "The reserved editor role is the host's own tab: the product names it and draws its glyph, so a record that shipped either would be naming one tab twice.",
      "if": {
        "properties": {
          "surfaceRole": {
            "const": "editor"
          }
        },
        "required": [
          "surfaceRole"
        ]
      },
      "then": {
        "properties": {
          "label": false,
          "icon": false
        }
      },
      "else": {
        "required": [
          "label"
        ]
      }
    }
  ]
}
