{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://schuylkillhub.com/schemas/hub-history-v1.json",
  "title": "hub-history-v1 — Schuylkill Hub machine-readable history feed",
  "description": "Contract for the /history-feed/* JSON files published by Schuylkill Hub: one index (manifest) file enumerating the collection feeds, plus one feed file per collection. Versioning is MAJOR.MINOR and additive-only within a MAJOR: consumers pin their supported MAJORs AND their collection identities in two tiers — REQUIRED (absence rejects: removing one within a MAJOR violates additive-only) and KNOWN ⊇ REQUIRED (a candidate listing any collection outside KNOWN rejects whole; a KNOWN collection absent from a candidate is tolerated during coordinated addition; every listed KNOWN collection is ingested) — and tolerate MINOR field additions (unknown FIELDS must be ignored; an unknown MAJOR must reject the entire candidate generation, never be ignored or partially ingested). The index carries contractDigest (since 1.1): sha256 over the normalized authoritative contract markdown, letting consumers verify their vendored contract mirror on every refresh. Digest verification is defined on the identity (uncompressed) representation. The published origin is https://schuylkillhub.com; audio media is served from https://media.schuylkillhub.com. Prose, definitions, and summaries are original Schuylkill Hub editorial content (CC-BY-4.0); per-entry sources[] identify underlying references and their own licenses. See docs/contracts/hub-history-v1.md in the publishing repository for the full consumer contract.",
  "oneOf": [
    { "$ref": "#/$defs/indexFile" },
    { "$ref": "#/$defs/collectionFile" }
  ],
  "$defs": {
    "feedId": { "const": "hub-history-v1" },
    "schemaVersion": {
      "type": "string",
      "pattern": "^1\\.\\d+$",
      "description": "MAJOR.MINOR. This schema describes MAJOR 1; a file declaring any other MAJOR must not validate here."
    },
    "url": {
      "type": "string",
      "format": "uri",
      "pattern": "^https?://",
      "description": "Absolute URL. Published feeds use https on schuylkillhub.com / media.schuylkillhub.com; http appears only in local development builds."
    },
    "isoDay": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
    "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
    "qid": { "type": "string", "pattern": "^Q\\d+$" },
    "collection": {
      "enum": ["topics", "people", "buildings", "monuments", "cultures", "sources", "glossary", "catalogs"]
    },
    "kind": {
      "enum": ["topic", "event", "person", "building", "monument", "culture", "source", "glossary-term", "catalog"]
    },
    "licenses": {
      "type": "object",
      "required": ["prose", "compilation"],
      "properties": {
        "prose": { "$ref": "#/$defs/url" },
        "compilation": { "$ref": "#/$defs/url" }
      }
    },
    "wrapperCommon": {
      "type": "object",
      "required": ["feedId", "schemaVersion", "generation", "$schemaRef", "aggregation", "aggregationLicense", "attribution", "licenses", "humanFeedUrl"],
      "properties": {
        "feedId": { "$ref": "#/$defs/feedId" },
        "schemaVersion": { "$ref": "#/$defs/schemaVersion" },
        "generation": {
          "$ref": "#/$defs/sha256",
          "description": "Content-derived corpus generation id (sha256 over every entry's id + contentHash; no build clocks). Identical on the index and every collection feed of one deploy. Consumers MUST verify it matches across the index and every fetched feed — together with the index's per-collection digest and bytes — and reject the ENTIRE refresh on any mismatch before committing anything, so a deploy landing mid-sync can never produce a half-committed mixed generation."
        },
        "$schemaRef": { "$ref": "#/$defs/url" },
        "aggregation": { "const": "Schuylkill Hub" },
        "aggregationLicense": { "type": "string", "minLength": 1 },
        "attribution": { "type": "string", "minLength": 1 },
        "licenses": { "$ref": "#/$defs/licenses" },
        "humanFeedUrl": { "$ref": "#/$defs/url" },
        "dateModified": {
          "$ref": "#/$defs/isoDay",
          "description": "Content-derived freshness stamp: the max dates.modified across the file's entries. Never a build clock — identical content rebuilds byte-identical."
        }
      }
    },
    "indexFile": {
      "allOf": [{ "$ref": "#/$defs/wrapperCommon" }],
      "type": "object",
      "required": ["contractDigest", "totalEntries", "collections"],
      "properties": {
        "contractDigest": {
          "$ref": "#/$defs/sha256",
          "description": "sha256 over the NORMALIZED authoritative consumer contract (docs/contracts/hub-history-v1.md): CRLF converted to LF, trailing spaces/tabs stripped from every line, exactly one trailing LF. Since 1.1. Publisher CI recomputes it from the committed contract; consumers vendoring a byte-identical contract mirror MUST compare their mirror's digest against this value on every refresh and treat a mismatch as a rejected candidate plus an operator alarm — documentation drift is never silent."
        },
        "totalEntries": { "type": "integer", "minimum": 0 },
        "collections": {
          "type": "array",
          "minItems": 1,
          "items": {
            "type": "object",
            "required": ["collection", "title", "feed", "page", "count", "digest", "bytes"],
            "properties": {
              "collection": { "$ref": "#/$defs/collection" },
              "title": { "type": "string", "minLength": 1 },
              "feed": { "$ref": "#/$defs/url" },
              "page": { "$ref": "#/$defs/url" },
              "count": { "type": "integer", "minimum": 0 },
              "digest": {
                "$ref": "#/$defs/sha256",
                "description": "sha256 of the feed file's exact served bytes in the IDENTITY (uncompressed) representation — the TOTAL-state fingerprint (unlike contentHash/generation it also changes when pre-resolved audio state flips). Consumers must request Accept-Encoding: identity or hash the fully DECODED response body (never compressed transfer bytes), re-hash, and compare; any mismatch rejects the whole refresh."
              },
              "bytes": { "type": "integer", "minimum": 1, "description": "Exact byte size of the feed file in the identity (uncompressed) representation — compare against the decoded body length, never a compressed response's transfer size." }
            }
          }
        }
      }
    },
    "collectionFile": {
      "allOf": [{ "$ref": "#/$defs/wrapperCommon" }],
      "type": "object",
      "required": ["collection", "title", "indexUrl", "count", "entries"],
      "properties": {
        "collection": { "$ref": "#/$defs/collection" },
        "title": { "type": "string", "minLength": 1 },
        "indexUrl": { "$ref": "#/$defs/url" },
        "count": { "type": "integer", "minimum": 0 },
        "withheldEntries": {
          "type": "integer",
          "minimum": 0,
          "description": "people feed only: how many person entries the living-person gate withheld from this feed (their pages exist; the feed republishes only verifiably historical subjects)."
        },
        "entries": { "type": "array", "items": { "$ref": "#/$defs/entry" } }
      }
    },
    "section": {
      "type": "object",
      "required": ["id", "heading", "prose", "anchor"],
      "properties": {
        "id": { "type": "string" },
        "heading": { "type": "string" },
        "prose": { "type": "string", "minLength": 1 },
        "anchor": {
          "type": "string",
          "description": "In-page fragment id matching the live page's rendered heading id (pageUrl + '#' + anchor deep-links). \"\" = the page top (intro prose)."
        }
      }
    },
    "source": {
      "type": "object",
      "required": ["name", "url", "license"],
      "properties": {
        "name": { "type": "string", "minLength": 1 },
        "url": { "$ref": "#/$defs/url" },
        "license": { "type": "string", "minLength": 1 },
        "date": { "type": "string" }
      }
    },
    "faqItem": {
      "type": "object",
      "required": ["q", "a"],
      "properties": {
        "q": { "type": "string", "minLength": 1 },
        "a": { "type": "string", "minLength": 1 }
      }
    },
    "timelineEntry": {
      "type": "object",
      "required": ["date", "title", "confidence", "source"],
      "properties": {
        "date": { "type": "string", "minLength": 1 },
        "title": { "type": "string", "minLength": 1 },
        "detail": { "type": "string" },
        "era": { "type": "string" },
        "lane": { "type": "string" },
        "confidence": {
          "type": "string",
          "minLength": 1,
          "description": "Sourcing-confidence tier as published (e.g. confirmed | probable). The hedge travels WITH the entry: consumers must surface non-confirmed tiers, never strip them."
        },
        "source": { "type": "string", "minLength": 1 },
        "url": { "$ref": "#/$defs/url" }
      }
    },
    "audio": {
      "oneOf": [
        { "type": "null" },
        {
          "type": "object",
          "required": ["refPath", "status"],
          "properties": {
            "refPath": { "type": "string", "minLength": 1 },
            "status": { "enum": ["fresh", "stale", "none"] },
            "url": { "$ref": "#/$defs/url" },
            "durationSeconds": { "type": "number", "exclusiveMinimum": 0 }
          },
          "if": { "properties": { "status": { "const": "fresh" } } },
          "then": { "required": ["url", "durationSeconds"] },
          "else": {
            "not": {
              "anyOf": [{ "required": ["url"] }, { "required": ["durationSeconds"] }]
            }
          }
        }
      ],
      "description": "Pre-resolved narration state. null = the entry kind has no audio unit. status fresh = narration matches the current text (url + duration present); stale = narrated once but the text has moved on (no url — text-first publishing, a NORMAL state); none = never narrated. Consumers never resolve media names themselves."
    },
    "entry": {
      "type": "object",
      "required": ["id", "kind", "pageUrl", "title", "description", "sections", "faq", "sources", "qids", "crossRefs", "keywords", "chunkingHint", "contentHash", "audio"],
      "properties": {
        "id": {
          "type": "string",
          "pattern": "^(topics|people|buildings|monuments|cultures|sources|glossary|catalogs)/[a-z0-9][a-z0-9-]*$",
          "description": "Stable id: <collection>/<slug>. crossRefs.people values join against people/<slug>; slugs equal the site's page slugs."
        },
        "kind": { "$ref": "#/$defs/kind" },
        "pageUrl": {
          "$ref": "#/$defs/url",
          "description": "Absolute canonical page URL — no trailing slash, serves HTTP 200 with no redirect."
        },
        "title": { "type": "string", "minLength": 1 },
        "description": { "type": "string", "minLength": 1 },
        "sections": { "type": "array", "items": { "$ref": "#/$defs/section" } },
        "faq": { "type": "array", "items": { "$ref": "#/$defs/faqItem" } },
        "timeline": { "type": "array", "items": { "$ref": "#/$defs/timelineEntry" } },
        "sources": { "type": "array", "items": { "$ref": "#/$defs/source" } },
        "qids": {
          "type": "object",
          "required": ["about", "mentions"],
          "properties": {
            "about": { "type": "array", "items": { "$ref": "#/$defs/qid" } },
            "mentions": { "type": "array", "items": { "$ref": "#/$defs/qid" } }
          }
        },
        "crossRefs": {
          "type": "object",
          "required": ["people", "towns"],
          "properties": {
            "people": { "type": "array", "items": { "type": "string", "minLength": 1 } },
            "towns": { "type": "array", "items": { "type": "string", "minLength": 1 } }
          },
          "description": "Typed slug references. people: refs to PUBLISHED people feed entries (join against people/<slug>) — guaranteed resolvable in-feed; a subject withheld by the living-person/prototype gates is never referenced, so withheld records are unreachable through internal references. towns: refs to EXTERNAL site pages (/towns/<slug> on the publishing site) — not feed entries."
        },
        "dates": {
          "type": "object",
          "required": ["published", "modified"],
          "properties": {
            "published": { "$ref": "#/$defs/isoDay" },
            "modified": { "$ref": "#/$defs/isoDay" },
            "event": { "$ref": "#/$defs/isoDay" }
          }
        },
        "keywords": { "type": "array", "items": { "type": "string", "minLength": 1 } },
        "chunkingHint": { "enum": ["prose", "catalog"] },
        "contentHash": {
          "$ref": "#/$defs/sha256",
          "description": "sha256 over the origin-free content subset in canonical key order — stable across deploy origins; changes iff the entry's content changes (audio state excluded)."
        },
        "audio": { "$ref": "#/$defs/audio" },
        "pronunciation": {
          "type": "string",
          "minLength": 1,
          "description": "glossary-term only: the published respelling (e.g. \"BOY-lo\")."
        },
        "recordCount": { "type": "integer", "minimum": 0 },
        "dataFeed": { "$ref": "#/$defs/url" }
      },
      "allOf": [
        {
          "if": {
            "properties": {
              "kind": { "enum": ["topic", "event", "person", "building", "monument", "culture", "source"] }
            }
          },
          "then": {
            "required": ["dates"],
            "properties": {
              "sources": { "type": "array", "minItems": 1 },
              "sections": { "type": "array", "minItems": 1 },
              "chunkingHint": { "const": "prose" }
            }
          }
        },
        {
          "if": { "properties": { "kind": { "const": "glossary-term" } } },
          "then": {
            "properties": {
              "sections": { "type": "array", "minItems": 1 },
              "chunkingHint": { "const": "prose" }
            }
          }
        },
        {
          "if": { "properties": { "kind": { "const": "catalog" } } },
          "then": {
            "required": ["recordCount"],
            "properties": {
              "chunkingHint": { "const": "catalog" },
              "audio": { "type": "null" }
            }
          }
        }
      ]
    }
  }
}
