{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://scrollcase.dev/schema/v3/scroll.schema.json",
  "title": "Box scroll",
  "description": "The declarative input to a build: an identity, a target, a pinned dependency environment, the assets to fetch, and the self-test the result must pass. A scroll is checked into the consumer's repository next to its lock file; everything a build produces is derived from it.\n\nOnly what a build cannot work out for itself is required. Anything the target or the identity already determines is optional here and filled in when the scroll is read, so a hand-written scroll declares decisions rather than restating them.\n\nOne box's targets differ in a handful of lines and agree on the rest, so a scroll may also be split: scrolls/<boxId>/scroll.json holds what they share, and each scrolls/<boxId>/<targetId>/scroll.json declares `extends` plus its own differences. Both halves are files of this shape; the joined result is what a build reads.",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "schemaVersion",
    "boxId",
    "version",
    "sourceRevision",
    "runtime",
    "selfTest",
    "pixiVersion"
  ],
  "properties": {
    "$schema": {
      "const": "https://scrollcase.dev/schema/v3/scroll.schema.json",
      "description": "Associates this file with the published Scrollcase v3 schema for editor validation, completion, and hover help."
    },
    "extends": {
      "const": "../scroll.json",
      "description": "Marks this file as one target's fragment of a box whose shared declarations live in scrolls/<boxId>/scroll.json. The value is fixed: a base is always the box directory's own scroll.json, so there is no path to get wrong and no chain to follow. The base and the fragment are joined into one effective scroll before anything else happens, and that effective scroll is what the build reads and what provenance records."
    },
    "schemaVersion": {
      "const": 3,
      "description": "Scrollcase wire version. Version 3 is the only active format.",
      "examples": [
        3
      ]
    },
    "scrollId": {
      "type": "string",
      "minLength": 1,
      "description": "Optional provenance identity. When omitted, Scrollcase derives it deterministically from boxId and the canonical target."
    },
    "scrollVersion": {
      "type": "string",
      "minLength": 1,
      "description": "Version of this declarative build input, recorded in provenance. Defaults to 1.0.0, which is what an authoring version means before anyone has had reason to change it.",
      "default": "1.0.0",
      "examples": [
        "1.0.0"
      ]
    },
    "boxId": {
      "$ref": "#/$defs/identifier"
    },
    "labels": {
      "$ref": "#/$defs/labels"
    },
    "version": {
      "type": "string",
      "minLength": 1,
      "description": "Version of the box this scroll produces, as it will appear in the release manifest."
    },
    "sourceRevision": {
      "type": "string",
      "minLength": 1,
      "description": "Upstream revision of the packaged source, recorded verbatim into provenance."
    },
    "target": {
      "$ref": "https://scrollcase.dev/schema/v3/target.schema.json",
      "description": "The (platform, arch, accelerator) triple this box is built for. Required in every scroll a build reads, and absent from a base: a base holds what its targets share, so declaring one there would name a target the box does not build. Enforced when the scroll is read rather than here, so a base file still validates in an editor."
    },
    "compatibility": {
      "type": "object",
      "additionalProperties": true,
      "properties": {
        "minHostAppVersion": {
          "type": "string",
          "minLength": 1,
          "description": "Lowest version of the installing application this box supports."
        },
        "maxHostAppVersionExclusive": {
          "type": "string",
          "minLength": 1
        },
        "minMacosVersion": {
          "type": "string",
          "minLength": 1
        },
        "minRamGb": {
          "type": "number",
          "exclusiveMinimum": 0
        },
        "minNvidiaDriverVersion": {
          "type": "string",
          "minLength": 1
        }
      },
      "description": "Constraints the installing host must satisfy. Copied through into the release manifest verbatim and never interpreted by the builder, so a project may declare its own alongside these. Defaults to empty: declaring no constraint is a legitimate answer, and inventing one would be a claim the project never made."
    },
    "runtime": {
      "$ref": "#/$defs/runtime"
    },
    "pixiVersion": {
      "type": "string",
      "minLength": 1,
      "description": "Pins the pixi release used to solve and install the conda-forge environment from the committed pixi.lock."
    },
    "condaDependencyLicenseAudit": {
      "type": "string",
      "minLength": 1,
      "description": "Path to the reviewed licence inventory derived from pixi.lock, which carries an SPDX licence per package. The build fails if the lock no longer matches what was reviewed."
    },
    "bundledLicenseDeclaration": {
      "type": "string",
      "minLength": 1,
      "description": "Path to the project's inventory of dependencies compiled *inside* the binaries this box ships. pixi.lock declares a licence per conda package, but it cannot see what was linked into a supplied executable before the build ever started, and nothing Scrollcase can read will tell it. So this half is declared rather than derived: the file is a JSON array of { name, version, declaredLicense, linkedInto } entries, and the build checks that every path it names is really in the box before carrying the list into the signed release. What belongs in it is the project's judgement; Scrollcase transports and signs what the project reviewed and never decides what a complete inventory is."
    },
    "cacheSubdir": {
      "type": "string",
      "minLength": 1,
      "description": "Payload directory the box's own large files live under — the destination a scroll's assets conventionally share. Defaults to cache/<boxId>."
    },
    "environment": {
      "type": "object",
      "description": "Environment variables the box requires when its interpreter runs. The declaration is copied into box.json and the signed release; its values override both the inherited host environment and caller-supplied values.",
      "propertyNames": {
        "minLength": 1,
        "pattern": "^[^=\\u0000]+$"
      },
      "additionalProperties": {
        "type": "string",
        "pattern": "^[^\\u0000]*$"
      }
    },
    "publishBaseUrl": {
      "type": "string",
      "minLength": 1,
      "description": "Base URL the built archive and its signed documents will be published under, so each can point at the next: the channel names the release document, and the release names the archive. It says nothing about the box's own assets \u2014 those carry a URL each \u2014 and nothing about what the box does at run time.\n\nOptional, and genuinely so. A box you build to run locally is never published, so there is nowhere for these documents to point and no value here would be true; the build omits both links rather than inventing an address. Nothing verifies this URL and no Scrollcase consumer reads one: an archive is found beside its release document and identified by its SHA-256.",
      "examples": [
        "https://boxes.example.org"
      ]
    },
    "assets": {
      "type": "array",
      "description": "Files fetched during the build. Every entry is size- and hash-checked before use, so a moved or replaced upstream file fails the build instead of silently changing the box. May be empty, and defaults to empty.",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "url",
          "relativePath",
          "sizeBytes",
          "sha256"
        ],
        "properties": {
          "url": {
            "type": "string",
            "minLength": 1
          },
          "relativePath": {
            "$ref": "#/$defs/payloadPath"
          },
          "sizeBytes": {
            "type": "integer",
            "exclusiveMinimum": 0
          },
          "sha256": {
            "$ref": "#/$defs/sha256"
          },
          "embed": {
            "type": "boolean",
            "default": true,
            "description": "Whether this file is packed into the archive. True, the default, makes the box self-contained: it installs with no network and works air-gapped. False leaves it out and carries its descriptor in the signed release instead, for the caller's distribution layer to materialize. The choice is per entry, so a box may ship a small entry point and defer a large dataset; consumers verify what was materialized before execution and never download it themselves."
          },
          "executable": {
            "type": "boolean",
            "default": false,
            "description": "Whether the file needs the executable bit. HTTP carries content and not permissions, so a downloaded file arrives with none; declaring it here is the only way a box can ship one that runs. The bit is synthesised into the archive from this declaration, never read off the build machine."
          }
        }
      }
    },
    "assetArchives": {
      "type": "array",
      "description": "Downloaded archives to expand into the payload. Extraction preserves files already present in the destination and refuses to overwrite them.",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "relativePath",
          "format",
          "destination"
        ],
        "properties": {
          "relativePath": {
            "$ref": "#/$defs/payloadPath"
          },
          "format": {
            "enum": [
              "zip",
              "tar.gz"
            ]
          },
          "destination": {
            "$ref": "#/$defs/payloadPath"
          },
          "stripComponents": {
            "type": "integer",
            "minimum": 0
          },
          "removeAfterExtract": {
            "type": "boolean"
          }
        }
      }
    },
    "localFiles": {
      "type": "array",
      "description": "Files copied from the consumer's own repository into the payload. A file already under the project's own version control needs no second copy of its identity here, and what ships is hashed into the signed release either way; declaring sha256 pins one that must not change without review, which suits a licence notice and not a script still being written.",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "sourcePath",
          "relativePath"
        ],
        "properties": {
          "sourcePath": {
            "type": "string",
            "minLength": 1
          },
          "relativePath": {
            "$ref": "#/$defs/payloadPath"
          },
          "sha256": {
            "$ref": "#/$defs/sha256",
            "description": "Optional pin. When present the build refuses a file whose contents no longer match."
          },
          "executable": {
            "type": "boolean",
            "default": false,
            "description": "Whether the file needs the executable bit. A copy does not carry the source file's mode, because a mode read off the build machine would vary with its umask and break the byte-identical rebuild; the bit is synthesised into the archive from this declaration instead."
          }
        }
      }
    },
    "prunePaths": {
      "type": "array",
      "description": "Payload paths deleted before packing, to keep the box to what it actually needs at run time. Pruning a distribution the lock requires is rejected.",
      "items": {
        "$ref": "#/$defs/payloadPath"
      }
    },
    "uncompressedPaths": {
      "type": "array",
      "description": "Payload paths stored in the archive instead of deflated, because their bytes are already compressed and re-compressing them costs build time while making the archive marginally larger. A path matches itself and everything beneath it, so one entry can name a single large file or the directory an expanded asset archive landed in. Declared assets are stored automatically; this is for anything else the project knows to be already compressed.",
      "items": {
        "$ref": "#/$defs/payloadPath"
      }
    },
    "selfTest": {
      "type": "object",
      "additionalProperties": false,
      "anyOf": [
        {
          "required": [
            "imports"
          ]
        },
        {
          "required": [
            "commands"
          ]
        }
      ],
      "not": {
        "required": [
          "code",
          "script"
        ]
      },
      "description": "Builder checks run against the payload before archiving. The signed release carries the probe — the imports and commands a consumer can repeat after extraction — while file assertions and the optional extra source stay builder-only. At least one of imports and commands is required: a box that proves nothing about itself is not a box worth signing.",
      "properties": {
        "imports": {
          "type": "array",
          "minItems": 1,
          "description": "Modules the runtime must be able to load. Meaningful to a runtime with a module system, which is why it is not the only shape a probe can take.",
          "items": {
            "type": "string",
            "minLength": 1
          }
        },
        "commands": {
          "type": "array",
          "minItems": 1,
          "description": "Invocations of the box's own declared execution, each with the exit code it must produce. This is the only probe shape available to a runtime with no module system, and it needs execution to be declared — there is nothing else to invoke.",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "args"
            ],
            "properties": {
              "args": {
                "type": "array",
                "description": "Arguments appended to the box's declared execution. Passed directly, without a shell. May be empty, which runs the entry point as the box would.",
                "items": {
                  "type": "string"
                }
              },
              "expectExitCode": {
                "type": "integer",
                "minimum": 0,
                "maximum": 255,
                "default": 0,
                "description": "Exit status the invocation must produce. Defaults to 0; a non-zero value suits a tool whose --version or --help deliberately exits otherwise."
              }
            }
          }
        },
        "files": {
          "type": "array",
          "description": "Files that must still exist after pruning, which is what stops an over-aggressive prune from shipping a broken box. Defaults to empty.",
          "items": {
            "$ref": "#/$defs/payloadPath"
          }
        },
        "code": {
          "type": "string",
          "minLength": 1,
          "description": "Extra source in the runtime's own language, executed after the imports succeed, for checks a bare import cannot make. Anything longer than an assertion belongs in script, where an editor can see what language it is."
        },
        "script": {
          "type": "string",
          "minLength": 1,
          "description": "Project path to a source file executed after the imports succeed, in place of code. The file is read at build time and run from the payload root, so a real self-test keeps its syntax highlighting, its linter, and its diffs instead of living inside a JSON string."
        }
      }
    },
    "execution": {
      "$ref": "https://scrollcase.dev/schema/v3/execution.schema.json"
    },
    "parity": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "script",
        "accelerators",
        "tolerances"
      ],
      "description": "An optional numerical gate: run a check inside the box on more than one accelerator and require the results to agree. This catches a mis-solved environment \u2014 CPU-only wheels shipped as CUDA, a broken BLAS \u2014 on the build machine rather than on a user's. The tool runs the check and enforces the thresholds; what the check computes, and what closeness is acceptable, belong to the project.",
      "properties": {
        "script": {
          "type": "string",
          "minLength": 1,
          "description": "Path inside the box, run with the box's own interpreter. It must print a JSON array of numbers, or an object with a \"values\" array."
        },
        "accelerators": {
          "type": "array",
          "minItems": 2,
          "items": {
            "enum": [
              "cpu",
              "metal",
              "cuda"
            ]
          },
          "description": "Accelerators to run under, each with its target's validation environment. The first is the reference the others are compared against \u2014 conventionally cpu, being the one available everywhere."
        },
        "tolerances": {
          "type": "object",
          "additionalProperties": false,
          "minProperties": 1,
          "description": "At least one bound. Absolute guards entries near zero, where relative error is meaningless; cosine similarity catches a result that drifted in direction rather than magnitude.",
          "properties": {
            "absolute": {
              "type": "number",
              "exclusiveMinimum": 0
            },
            "relative": {
              "type": "number",
              "exclusiveMinimum": 0
            },
            "minimumCosine": {
              "type": "number",
              "maximum": 1
            }
          }
        }
      }
    }
  },
  "$defs": {
    "identifier": {
      "type": "string",
      "pattern": "^[a-z0-9]+(?:[-.][a-z0-9]+)*$"
    },
    "runtime": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "id"
      ],
      "description": "What runs inside the box. A target says which machine the box is for; this says what executes on it — which is a different question, and until version 3 the format never asked it: a box declared a Python interpreter path and Python execution kinds and nothing that said \"Python\".",
      "properties": {
        "id": {
          "enum": [
            "python",
            "node",
            "native"
          ],
          "description": "The runtime the box carries. The list is closed rather than free-form: each id implies a payload layout, a set of execution kinds and an argv rule that a consumer has to already know, so an unrecognised one is a box that cannot be run, not a box with an unusual label."
        },
        "version": {
          "type": "string",
          "minLength": 1,
          "description": "The runtime's own version, solved into the box and recorded in provenance. Required by any runtime whose layout depends on it — Python names its standard library after major.minor — and legitimately absent for one that has no interpreter to version, which is why the format does not demand it.",
          "examples": [
            "3.11.15"
          ]
        },
        "entryPoint": {
          "type": "string",
          "minLength": 1,
          "description": "The runtime's own executable, relative to the box root. The runtime's layout for a given target admits exactly one value, so this is derived when omitted and still checked against the layout when declared. Absent for a runtime that has no separate executable to name.",
          "examples": [
            "venv/bin/python"
          ]
        }
      }
    },
    "labels": {
      "type": "object",
      "description": "Free-form annotations carried through into the signed release untouched. Scrollcase never reads a label; it exists so a project can record what it needs to record — the upstream model a box packages, the team that owns it, the ticket it came from — without the format having to grow a field, and without the format claiming to know what any project's boxes are about.",
      "propertyNames": {
        "$ref": "#/$defs/identifier"
      },
      "additionalProperties": {
        "type": "string",
        "minLength": 1
      },
      "examples": [
        {
          "model": "example-org/example-model",
          "owner": "platform-team"
        }
      ]
    },
    "sha256": {
      "type": "string",
      "pattern": "^[a-f0-9]{64}$"
    },
    "payloadPath": {
      "type": "string",
      "minLength": 1,
      "pattern": "^(?!/)(?![A-Za-z]:)(?!.*\\\\)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*//).+$",
      "description": "A forward-slash path inside the box payload: relative, non-empty, and unable to escape the payload root.",
      "examples": [
        "cache/example-box/data.bin"
      ]
    }
  }
}
