{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://netshow.ai/schema/alive-package-1.schema.json",
  "title": "NetShow Alive — Portable Avatar Package (alive-package-1)",

  "description": "THE CONTRACT for the portable Alive Agent Avatar package envelope.\n\nDERIVED FROM THE IMPLEMENTATION, NOT FROM PROSE. Every constraint below is traceable to a line of public/alive-site/alive-package.js — the ONE shared envelope implementation every NetShow Alive surface uses. Where the written spec (ALIVE-AVATAR-PACKAGE-SPEC-20260718.md) and the code disagree, THE CODE WINS and the divergence is named in the field's own description.\n\nTHE CENTRAL DESIGN FACT — read this before you read anything else. `parse()` (alive-package.js:128-175) is a LENIENT normalizer, not a strict rejecter. For most envelope fields it SUBSTITUTES A DEFAULT rather than refusing. Only six conditions actually reject:\n  fmt !== 'alive-package-1'         -> 'wrong-fmt'              (:134)\n  spec major !== '1'                -> 'wrong-spec'             (:135-137)\n  type not in the six              -> 'unknown-type'           (:138)\n  payload not an object            -> 'no-payload'              (:139)\n  payload[type] missing/not object -> 'payload-type-mismatch'   (:140-142)\n  integrity present but malformed  -> 'bad-integrity-shape'     (:146-150)\n(plus 'not-json' :131 and 'not-object' :133 before any field is read.)\n\nA schema that REQUIRED name/exportedAt/generator/license/requires/ext would therefore reject documents the real gate ACCEPTS. This file does not make that mistake. `required` here lists exactly what the gate rejects for, and nothing else. What a correct PRODUCER should emit is a separate, opt-in profile: $defs.producerProfile.\n\nTWO PROFILES, ONE FILE:\n  · the root schema      = CONSUMER truth. Accepts exactly what parse() accepts. Use this to decide whether a document you were handed is loadable.\n  · $defs.producerProfile = PRODUCER truth. The complete envelope wrap() (:93-122) always emits, and which 48/48 packages in the docroot carry. Use this to check your emitter.\n\nWHAT THIS SCHEMA CANNOT EXPRESS — enforced by nsv-validate.mjs, never by JSON Schema:\n  1. INTEGRITY. `integrity.payload` must equal sha-256(canonJson(payload)). A schema can check the SHAPE of the hash; only a recompute can check the VALUE. See $comment on the integrity property, and §canonJson below.\n  2. PAYLOAD/TYPE AGREEMENT beyond key presence. The schema requires payload to have a property named by `type` (via the six conditional branches in allOf). It cannot express 'and no other key', because parse() does not reject extra payload keys — it DROPS them (:143-144). A document with payload {ascii:{},svg:{}} and type 'ascii' is ACCEPTED by the gate and normalized to {ascii:{}}. This schema matches that behaviour deliberately.\n  3. PAYLOAD CONTENT. parse() is the STRUCTURE gate only; the payload stays UNTRUSTED after it (:13-20). Every consumer applies its own per-type sanitize gate before use. This schema therefore says nothing about payload interiors — asserting otherwise would be the second vocabulary the estate forbids.\n\ncanonJson — the deterministic byte rule (alive-package.js:57-77). Object keys sorted ascending by code unit; NO whitespace; arrays keep order; `undefined` values skipped; non-finite numbers THROW (a hash over lossy bytes would be a lie). Any reimplementation must match byte-for-byte or every integrity check will disagree.\n\nCORPUS AS OF 2026-08-05: 48 packages under public/. 48/48 parse OK; 48/48 verifyIntegrity === 'pass' recomputed under node 22. type distribution: hybrid 38, svg 5, kinetic-typography 2, sketch-line 2, ascii 1, live-vrm 0.",

  "type": "object",

  "required": ["fmt", "spec", "type", "payload"],

  "$comment": "required = exactly the four fields whose absence or wrongness makes parse() return {ok:false}. fmt :134 · spec :135 · type :138 · payload :139. Everything else is defaulted by the gate and is therefore optional HERE, however strongly a producer ought to emit it.",

  "properties": {

    "fmt": {
      "const": "alive-package-1",
      "description": "Format discriminator. parse() :134 — `if (v.fmt !== PKG_FMT) return {ok:false, reason:'wrong-fmt'}`. PKG_FMT is declared at :29. A consumer that does not recognise this value MUST reject the whole document rather than guess. detect() :199 uses the same string to distinguish a package from the legacy pre-package bundle 'alive-avatar-1' (:40, :200), which is NOT this format and is not described by this file."
    },

    "spec": {
      "type": "string",
      "pattern": "^1(\\..*)?$",
      "description": "Spec version. MAJOR MUST MATCH; minors are additive. parse() :135-137 — `v.spec.split('.')[0] !== PKG_SPEC.split('.')[0]` with PKG_SPEC = '1.0' (:30).\n\nTHE PATTERN IS DELIBERATELY LOOSER THAN IT LOOKS, because the gate is. `split('.')[0] === '1'` accepts the bare string '1', accepts '1.0.0', and accepts '1.anything'. It rejects '2.0', '01.0' and '1x.0'. This regex reproduces that set exactly. The staged draft agent-schema-v1.json used `^1\\.[0-9]+$` for the sibling nsv-agent-1 format — STRICTER than its gate, and it would falsely reject a document the gate loads. Named here so the divergence is a decision, not an accident. $defs.producerProfile pins the strict form for emitters.",
      "examples": ["1.0"]
    },

    "product": {
      "const": "NetShow Alive",
      "description": "Branding self-description. NOT CHECKED BY parse() — it is OVERWRITTEN unconditionally at :156 with PKG_PRODUCT (:32). A document carrying product:'Acme' is accepted and silently normalized. `const` is stated here because it is the only legal value a producer may emit and 48/48 of the corpus carries it; it is NOT a claim that the gate enforces it. Never an authorization claim."
    },

    "kind": {
      "const": "alive-agent-avatar",
      "description": "Document kind. Same status as `product`: OVERWRITTEN at :157 from PKG_KIND (:31), never checked. Stated as const for producers; not gate-enforced. 48/48 of the corpus carries it."
    },

    "type": {
      "enum": ["svg", "ascii", "sketch-line", "hybrid", "kinetic-typography", "live-vrm"],
      "description": "THE wire value — which of the six first-class avatar types this package carries. parse() :138 — `AVATAR_TYPES.indexOf(v.type) < 0` -> 'unknown-type'. The whitelist is declared at :33 in this exact order.\n\n'live-vrm' is the sixth type (S36 type-6 wiring, spec §4.6, comment at :34-36). Its payload gate lives at the consuming surface (nsv-liveface-bind.js nsvValidateLiveVrmPayload), exactly like every other type. As of 2026-08-05 ZERO live-vrm packages exist in the docroot — the only door that can make one (nsv-seal-livevrm.js) is staged and unwired (SEAL_WIRING.wiredIntoShippedSurface === false)."
    },

    "name": {
      "type": "string",
      "maxLength": 60,
      "description": "Display name. NOT gate-required and NOT gate-rejected: parse() :159 runs it through cleanPkgName() (:45-47) which STRIPS the characters <>&\"'` , collapses whitespace, trims, and SLICES to 60 — then falls back to the literal 'avatar' if the result is empty. So an over-long or angle-bracket-bearing name is TRUNCATED/CLEANED, never refused; and a non-string becomes '' -> 'avatar'.\n\nmaxLength 60 is stated because that is the post-normalization ceiling a producer must respect if the name is to survive the round trip unchanged. A document exceeding it is still LOADABLE — it just will not read back the same. That is a producer defect, not a consumer refusal, and nsv-validate.mjs reports it as a WARNING rather than a failure when checking the consumer profile."
    },

    "exportedAt": {
      "type": "string",
      "maxLength": 40,
      "description": "When this package was written. wrap() :107 emits `new Date().toISOString()`. parse() :160 SLICES to 40 chars and substitutes '' when absent or non-string — never refuses. No format is enforced anywhere in the code; ISO-8601 is convention, and this schema does not assert `format: date-time` because doing so would claim a rule the gate does not have.\n\nNOTE FOR PRODUCERS: exportedAt is INSIDE the envelope but OUTSIDE the integrity hash — the hash covers `payload` only (:99, :184). Re-exporting the same avatar at a different time changes the file bytes but NOT the integrity hash. That is by design and is what lets ext.join.body.packageSha256 (nsv-agent-1) pin a body across re-exports while fileSha256 does not."
    },

    "generator": {
      "type": "object",
      "description": "Provenance of the emitting surface. Informational only — NEVER an authorization claim. parse() :161-164 rebuilds this object from exactly two keys and drops all others; a non-object becomes {surface:'unknown', host:''}. Because parse() rebuilds rather than validates, no shape here is ever refused.",
      "properties": {
        "surface": {
          "type": "string",
          "maxLength": 80,
          "description": "Which surface produced it. parse() :162 slices to 80 and substitutes 'unknown' for a non-string. Truncated, never refused."
        },
        "host": {
          "type": "string",
          "maxLength": 120,
          "description": "Host that produced it. parse() :163 slices to 120 and substitutes '' for a non-string. 48/48 of the corpus carries this key (often as the empty string, which is the honest value for a filesystem-emitted package)."
        }
      },
      "additionalProperties": false,
      "$comment": "additionalProperties:false is PRODUCER guidance expressed at consumer level, and it is safe: parse() :161-164 constructs the output object from the two keys alone, so an extra key can never survive a round trip. Declaring it false tells an emitter the truth — anything else you put here is discarded. It does NOT mean the gate refuses such a document; the gate accepts and drops. 48/48 of the corpus carries exactly {surface, host}."
    },

    "license": {
      "type": "object",
      "description": "Rights over THIS PACKAGE. parse() :165-168 rebuilds from exactly two keys; a non-object becomes {rights:'personal-use', owner:null}.\n\nTWO LICENCES EXIST AND ARE NEVER CONFLATED (READY-FOR-FACELAB-1.md §5): this block governs the sealed avatar package. A live-vrm payload separately carries `model.license` governing the .vrm FILE. nsv-seal-livevrm.js keeps them in separate fields and never copies one into the other.",
      "properties": {
        "rights": {
          "type": "string",
          "maxLength": 60,
          "description": "parse() :166 slices to 60, substitutes 'personal-use' for a non-string. Not an enum anywhere in the code — 'personal-use' is the default, not the only value."
        },
        "owner": {
          "type": ["string", "null"],
          "maxLength": 120,
          "description": "parse() :167 slices to 120, substitutes null for a non-string. null is the HONEST value for an unowned/self-owned package, never missing data."
        }
      },
      "additionalProperties": false,
      "$comment": "Same status as generator.additionalProperties: parse() :165-168 rebuilds from the two keys, so extras cannot survive. Producer guidance, not a gate refusal."
    },

    "requires": {
      "type": "object",
      "description": "What a destination needs in order to wear this body. parse() :169 passes the object through OPAQUELY when it is an object, and substitutes {renderer: type} otherwise. It is NEVER inspected, so nothing here is ever refused.\n\nDELIBERATELY OPEN — do not close it. The corpus census found 48/48 carrying `renderer` and 1/48 additionally carrying `transcript`. An `additionalProperties:false` here would falsely reject a real, currently-served, currently-loadable package. This is exactly the class of error this file exists to prevent.\n\nnsv-seal-livevrm.js SEAL_REQUIRES (:192) is {renderer:'live-vrm', webgl:true} — spec §4.6's recommended block — which is a third distinct shape and further proof the field must stay open.",
      "properties": {
        "renderer": {
          "type": "string",
          "description": "Conventionally equals `type`. wrap() :116 defaults it to the type when no requires object is supplied. 48/48 of the corpus carries it. NOT enforced to equal `type` anywhere in the code, so this schema does not enforce it either."
        }
      }
    },

    "integrity": {
      "type": ["object", "null"],
      "description": "The payload seal. `null` is a FIRST-CLASS, HONEST value meaning 'no WebCrypto was available where this was written' — wrap() :117 emits null rather than faking a hash, and sha256Hex() :81-87 returns null rather than throwing. Absent is equally legal.\n\nSHAPE IS GATE-ENFORCED, VALUE IS NOT. parse() :146-150 refuses 'bad-integrity-shape' unless algo === 'sha-256' AND canon === 'sorted-keys-json' AND payload matches /^[0-9a-f]{64}$/ — note LOWERCASE hex only, an uppercase digest is REFUSED. Those three checks are what this subschema reproduces.",
      "$comment": "WHAT THIS SUBSCHEMA CANNOT DO — the whole reason nsv-validate.mjs exists. verifyIntegrity() :181-187 recomputes sha-256(canonJson(pkg.payload)) and compares. Four verdicts: 'pass' (match), 'fail' (mismatch — MUST reject whole at the import site, spec §5), 'absent' (no integrity block — accept-with-honesty for v1), 'unavailable' (no WebCrypto here — accept-with-honesty). A JSON Schema can check that the hash is 64 lowercase hex characters. It cannot check that it is the RIGHT 64 characters. A tampered payload with a correctly-shaped stale hash passes this subschema and fails the recompute; that is the single most important false-pass this contract's tooling closes.",
      "properties": {
        "algo": { "const": "sha-256", "description": "parse() :147 — any other value is 'bad-integrity-shape'." },
        "canon": { "const": "sorted-keys-json", "description": "parse() :147 — names the canonicalisation (canonJson :57-77). Any other value is 'bad-integrity-shape'." },
        "payload": {
          "type": "string",
          "pattern": "^[0-9a-f]{64}$",
          "description": "sha-256 of canonJson(payload) as LOWERCASE hex. parse() :148 applies exactly this regex. The digest covers the payload BODY ({<type>: {...}}), not the envelope — so envelope edits (name, exportedAt, license) do not disturb it."
        }
      },
      "required": ["algo", "canon", "payload"],
      "additionalProperties": false,
      "$comment#2": "additionalProperties:false is exact here, unlike generator/license: parse() :151 rebuilds integrity from precisely these three keys, and :146-150 has already refused any document whose block is malformed. An extra key is dropped."
    },

    "payload": {
      "type": "object",
      "description": "The typed body. Exactly one key, named by `type`, whose value is an object. parse() :139-142 refuses 'no-payload' when this is not an object and 'payload-type-mismatch' when payload[type] is missing or is not an object (note: `ownProp` :42 is used, so an inherited property does NOT satisfy the check).\n\nTHE PAYLOAD STAYS UNTRUSTED AFTER parse() (:13-20, the house untrusted-import law). parse() never blesses payload content; every consumer applies its own per-type sanitize gate before use — the Hybrid studio's own-prop whitelist is the reference implementation, and nsvValidateLiveVrmPayload is the live-vrm one. THIS SCHEMA THEREFORE SPECIFIES NO PAYLOAD INTERIOR. That silence is the contract, not an omission.\n\nEXTRA KEYS ARE DROPPED, NOT REFUSED. parse() :143-144 copies only payload[type] into the normalized package. A document with two type keys is accepted and normalized down to one. minProperties:1 is asserted (the declared type key must exist) but no maximum, matching that behaviour.",
      "minProperties": 1
    },

    "ext": {
      "type": "object",
      "description": "Extension seat. parse() :172 passes it through when it is an object and substitutes {} otherwise. Never inspected, never refused, deliberately open.\n\nTHIS IS THE SEAT THE SIBLING FORMAT USES. nsv-agent-1 carries its join block at ext.join — see nsv-agent-1.schema.json. The two formats are distinct documents; a package's ext and an agent's ext are different seats on different envelopes."
    }
  },

  "additionalProperties": false,
  "$comment#top": "additionalProperties:false at the top level is PRODUCER-ACCURATE and CORPUS-VERIFIED (0 unknown top-level keys across 48/48). It is NOT gate-enforced: parse() :153-173 rebuilds the envelope from a fixed key list, so an unknown top-level key is DROPPED, never refused — the header comment at :15-16 states this as law ('Unknown top-level keys are dropped'). If you are validating a document you were handed and it carries an extra key, the gate will still load it. nsv-validate.mjs reports that case as a WARNING (`unknown-key-dropped`) and not a FAIL, so consumer-truth and producer-truth stay distinguishable.",

  "allOf": [
    {
      "$comment": "PAYLOAD/TYPE AGREEMENT — parse() :140-142. Expressed as six if/then branches because JSON Schema has no way to say 'the object must have a key whose name is the value of a sibling property'. Each branch: when type is T, payload must have property T and that property must be an object. This is the closest a draft 2020-12 document can get to the gate's rule, and it is exact for the six legal values (an illegal type is already refused by the enum above).",
      "allOf": [
        { "if": { "properties": { "type": { "const": "svg" } }, "required": ["type"] },
          "then": { "properties": { "payload": { "required": ["svg"], "properties": { "svg": { "type": "object" } } } } } },
        { "if": { "properties": { "type": { "const": "ascii" } }, "required": ["type"] },
          "then": { "properties": { "payload": { "required": ["ascii"], "properties": { "ascii": { "type": "object" } } } } } },
        { "if": { "properties": { "type": { "const": "sketch-line" } }, "required": ["type"] },
          "then": { "properties": { "payload": { "required": ["sketch-line"], "properties": { "sketch-line": { "type": "object" } } } } } },
        { "if": { "properties": { "type": { "const": "hybrid" } }, "required": ["type"] },
          "then": { "properties": { "payload": { "required": ["hybrid"], "properties": { "hybrid": { "type": "object" } } } } } },
        { "if": { "properties": { "type": { "const": "kinetic-typography" } }, "required": ["type"] },
          "then": { "properties": { "payload": { "required": ["kinetic-typography"], "properties": { "kinetic-typography": { "type": "object" } } } } } },
        { "if": { "properties": { "type": { "const": "live-vrm" } }, "required": ["type"] },
          "then": { "properties": { "payload": { "required": ["live-vrm"], "properties": { "live-vrm": { "type": "object" } } } } } }
      ]
    }
  ],

  "$defs": {

    "producerProfile": {
      "$comment": "OPT-IN. Not referenced by the root schema — a consumer must never be held to it. This is what wrap() (:93-122) always emits and what 48/48 docroot packages carry. Validate against this to check YOUR EMITTER; validate against the root to check a document SOMEONE HANDED YOU. nsv-validate.mjs runs both and reports them on separate lines.",
      "description": "The complete envelope a correct producer emits: all thirteen keys, the strict spec form, and a real (non-null) integrity block.\n\nWhy `integrity` is required here but nullable at the root: wrap() emits null only when WebCrypto is unavailable, which on a server-side emitter (node 22, where crypto.subtle is always present) never happens. An emitter that ships a null seal on node has a bug. A BROWSER emitter in a non-secure context legitimately ships null — such a package is honest and loadable, and the root schema accepts it.",
      "type": "object",
      "required": ["fmt", "spec", "product", "kind", "type", "name", "exportedAt", "generator", "license", "requires", "integrity", "payload", "ext"],
      "properties": {
        "spec": {
          "type": "string",
          "pattern": "^1\\.[0-9]+$",
          "description": "STRICTER THAN THE GATE ON PURPOSE. The gate accepts '1', '1.0.0' and '1.anything'; an emitter should write MAJOR.MINOR and nothing else. 48/48 of the corpus is exactly '1.0'."
        },
        "integrity": {
          "type": "object",
          "description": "A producer running where sha-256 is available MUST seal. null is honest only where WebCrypto is genuinely absent."
        }
      }
    },

    "divergences": {
      "$comment": "DOCUMENTATION ONLY — never applied to a document. Added because an intake lane (s45-intake-ad-20260805) built probes aimed at exactly these points, and an auditor deserves one place to read them rather than four field descriptions. nsv-validate.mjs marks a document that trips only these as SCHEMA-STRICTER-THAN-GATE, and still REFUSES it — FAIL, exit 1. The shipped gate may load such a document; the CLI is stricter by design (ruled 2026-08-05: keep the CLI fail-closed). The marker names the disagreement; it does not soften the verdict.",
      "description": "STRICTER THAN parse() — a document can be GATE-VALID and still fail these. Every one is a deliberate producer rule, not a claim about what the gate refuses.\n\n  1. additionalProperties:false at the top level. parse() DROPS an unknown top-level key (:153-173 rebuilds the envelope from a fixed list; the header states it as law at :15-16). Corpus-verified: 0 unknown keys across 48 docroot packages. Probed by A2.probe.json (`zzUnknownTop`) — gate accepts, this contract refuses.\n  2. `name` must be a STRING. parse() :159 runs cleanPkgName() over anything: a number becomes its digits, null becomes 'avatar'. Probed by A3.probe.json (`name` as an integer) — gate accepts and coerces, this contract refuses. A producer that emits a non-string name is emitting a value it will not read back.\n  3. maxLength on every capped string (name 60, generator.surface 80, generator.host 120, license.rights 60, license.owner 120, exportedAt 40). EVERY ONE OF THESE IS A SLICE IN THE GATE, NOT A REFUSAL (:159, :162, :163, :166, :167, :160). Probed by A6.probe.json (100-char license.rights) — gate accepts and truncates to 60, this contract refuses. The cap is the round-trip ceiling: exceed it and the document will not read back as written.\n  4. generator/license additionalProperties:false. parse() rebuilds both from their two known keys, so extras cannot survive a round trip — refusing them tells an emitter the truth, but the gate merely discards them.\n\nLOOSER THAN IT LOOKS, ON PURPOSE:\n  1. `spec` accepts '1', '1.0.0' and '1.anything' because the gate tests only split('.')[0] === '1' (:135-137). The stricter MAJOR.MINOR form is producer-only, in $defs.producerProfile.\n  2. `requires` is OPEN. 1 of the 48 docroot packages carries a `transcript` key beside `renderer`; closing it would reject a package the estate serves.\n  3. `payload` permits extra keys. parse() :143-144 drops them and the integrity hash covers the REDUCED body, so such a document is loadable AND its integrity passes.\n  4. No payload interior is specified at all. parse() is a structure gate only; the payload stays untrusted and each consumer sanitizes it.",
      "type": "null"
    },

    "legacyHybridBundle": {
      "$comment": "NOT PART OF alive-package-1. Documented here because detect() (:193-201) returns 'legacy-hybrid' for it and importers accept it raw for continuity, so a developer autodetecting formats WILL meet it and must not mistake it for a package.",
      "description": "The pre-package Hybrid bundle from the V21 studio, fmt 'alive-avatar-1' (declared :40). It has no envelope, no integrity block and no typed payload wrapper. Exporters never emit it; importers still read it. It crosses its own sanitize gate exactly as a package payload does. nsv-validate.mjs identifies it by name and declines to validate it against this contract rather than reporting a misleading failure.",
      "type": "object",
      "properties": { "fmt": { "const": "alive-avatar-1" } },
      "required": ["fmt"]
    }
  }
}
