openapi: 3.1.0
info:
  title: TextRefs Registry & Resolver
  version: 0.1.0
  summary: HTTP contract for the canonical `/id/` namespace on textrefs.org.
  description: |
    Every TextRefs record is served at two static URLs:

    - `/id/{type}/{key}/` — HTML for browsers.
    - `/id/{type}/{key}.json` — JSON-LD for machines.

    There is **no** `Accept`-header content negotiation. Clients either
    follow the `<link rel="alternate" type="application/ld+json" href="…json">`
    advertised in the HTML head, or simply append `.json` to the canonical
    URL. JSON bodies carry the JSON-LD `@context` at
    [`/contexts/v1.jsonld`](https://textrefs.org/contexts/v1.jsonld); the
    Zod schemas in
    [`standard/schema/`](https://github.com/textrefs/textrefs.org/tree/main/standard/schema)
    remain the source of truth.

    The `Accept-Language` / `edition`-based 303 redirect on
    `/id/ref/{uuid}` to a resolver-target manifestation is planned but not
    yet live.

    A client that does not yet know a key starts at `/reg/works.json` or
    `/reg/systems.json`. A client that knows a work key, a citation system
    key, and a locator reads the reference UUID from
    `/reg/work/{key}/aliases.json`. It needs no UUIDv5 implementation of its
    own. `/dump/` carries the whole registry as newline-delimited JSON, plus
    the whole alias table as one JSON object, for bulk consumers.

    The site is static, and the host derives every `Content-Type` from the
    file extension. This contract therefore declares the media type that
    the host actually sends, not the media type that best describes the
    payload. A record or collection `.json` body is JSON-LD by content and
    arrives as `application/json`. The two alias artifacts are plain JSON
    and arrive the same way. A `.jsonl` body is newline-delimited JSON by
    content and arrives as `application/octet-stream`. This contract is
    published at
    [`/api/openapi.yaml`](https://textrefs.org/api/openapi.yaml), byte for
    byte as it is written, and arrives as `text/yaml`. A miss carries no JSON
    at all: the host serves the site's HTML 404 page. Parse by documented
    shape, not by response header.
  license:
    name: AGPL-3.0-or-later
    url: https://www.gnu.org/licenses/agpl-3.0.html
  contact:
    name: TextRefs
    url: https://textrefs.org/community/
servers:
  - url: https://textrefs.org
    description: Production
tags:
  - name: Records
    description: Canonical record URLs under `/id/`.
  - name: Collections
    description:
      Registry-wide JSON-LD collections and per-work locator indexes under
      `/reg/`. Entry points for a client that knows no key, or that knows a
      locator but no reference UUID.
  - name: Bulk
    description:
      Whole-registry artifacts under `/dump/`, rebuilt on every deploy. The
      `.jsonl` bodies are newline-delimited JSON, and the host serves them
      as `application/octet-stream`. The `.json` bodies are single JSON
      objects, and the host serves them as `application/json`. The site runs
      on GitHub Pages and inherits its soft limits. A bulk consumer therefore
      reads these artifacts, and does not crawl the record pages one by one.
      [`/dump/`](https://textrefs.org/dump/) lists them with their sizes and
      hashes.
paths:
  /id/work/{key}/:
    get:
      tags: [Records]
      summary: Work — HTML
      operationId: getWorkHtml
      parameters:
        - $ref: '#/components/parameters/FlatKey'
      responses:
        '200':
          description: Work landing page.
          content:
            text/html:
              schema: { type: string }
        '404': { $ref: '#/components/responses/NotFound' }
  /id/work/{key}.json:
    get:
      tags: [Records]
      summary: Work — JSON-LD
      operationId: getWorkJson
      parameters:
        - $ref: '#/components/parameters/FlatKey'
      responses:
        '200':
          description: Work record as JSON-LD.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Work' }
        '404': { $ref: '#/components/responses/NotFound' }
  /id/system/{key}/:
    get:
      tags: [Records]
      summary: CitationSystem — HTML
      operationId: getCitationSystemHtml
      parameters:
        - $ref: '#/components/parameters/FlatKey'
      responses:
        '200':
          description: CitationSystem landing page.
          content:
            text/html:
              schema: { type: string }
        '404': { $ref: '#/components/responses/NotFound' }
  /id/system/{key}.json:
    get:
      tags: [Records]
      summary: CitationSystem — JSON-LD
      operationId: getCitationSystemJson
      parameters:
        - $ref: '#/components/parameters/FlatKey'
      responses:
        '200':
          description: CitationSystem record as JSON-LD.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CitationSystem' }
        '404': { $ref: '#/components/responses/NotFound' }
  /id/ref/{uuid}/:
    get:
      tags: [Records]
      summary: CanonicalReference — HTML
      operationId: getCanonicalReferenceHtml
      parameters:
        - $ref: '#/components/parameters/Uuid'
      responses:
        '200':
          description: CanonicalReference landing page.
          content:
            text/html:
              schema: { type: string }
        '404': { $ref: '#/components/responses/NotFound' }
  /id/ref/{uuid}.json:
    get:
      tags: [Records]
      summary: CanonicalReference — JSON-LD
      operationId: getCanonicalReferenceJson
      parameters:
        - $ref: '#/components/parameters/Uuid'
      responses:
        '200':
          description: CanonicalReference record as JSON-LD.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CanonicalReference' }
        '404': { $ref: '#/components/responses/NotFound' }
  /id/mapping/{uuid}/:
    get:
      tags: [Records]
      summary: MappingAssertion — HTML
      operationId: getMappingAssertionHtml
      parameters:
        - $ref: '#/components/parameters/Uuid'
      responses:
        '200':
          description: MappingAssertion landing page.
          content:
            text/html:
              schema: { type: string }
        '404': { $ref: '#/components/responses/NotFound' }
  /id/mapping/{uuid}.json:
    get:
      tags: [Records]
      summary: MappingAssertion — JSON-LD
      operationId: getMappingAssertionJson
      parameters:
        - $ref: '#/components/parameters/Uuid'
      responses:
        '200':
          description: MappingAssertion record as JSON-LD.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MappingAssertion' }
        '404': { $ref: '#/components/responses/NotFound' }
  /reg/works.json:
    get:
      tags: [Collections]
      summary: Work collection — JSON-LD
      operationId: getWorkCollection
      description:
        Each item in `@graph` is the same record that `/id/work/{key}.json`
        serves, without its own `@context`. The collection carries one
        `@context` for the whole graph.
      responses:
        '200':
          description:
            Every Work record in the registry, in every status, sorted by
            key.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkCollection' }
  /reg/systems.json:
    get:
      tags: [Collections]
      summary: CitationSystem collection — JSON-LD
      operationId: getCitationSystemCollection
      description: Each item in `@graph` is the same record that
        `/id/system/{key}.json` serves, without its own `@context`. The
        collection carries one `@context` for the whole graph.
      responses:
        '200':
          description:
            Every CitationSystem record in the registry, in every status,
            sorted by key.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SystemCollection' }
  /reg/work/{key}/aliases.json:
    get:
      tags: [Collections]
      summary: Work locator index — JSON
      operationId: getWorkAliasIndex
      description: Maps every locator of one work to the UUID of its
        CanonicalReference, grouped by citation system key. A client that
        knows a passage as work, citation system, and locator reads the UUID
        here, then fetches `/id/ref/{uuid}.json`. It needs no UUIDv5
        implementation of its own. The body is plain JSON, not JSON-LD, and
        carries no `@context`.
      parameters:
        - $ref: '#/components/parameters/FlatKey'
      responses:
        '200':
          description:
            Every reference of this work, in every status, keyed by citation
            system key and then by locator.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkAliasIndex' }
        '404': { $ref: '#/components/responses/NotFound' }
  /dump/works.jsonl:
    get:
      tags: [Bulk]
      summary: Work dump — NDJSON
      operationId: getWorksDump
      responses:
        '200':
          description:
            Newline-delimited JSON, one Work record object per line, with
            no `@context`.
          content:
            application/octet-stream:
              schema: { type: string }
  /dump/citation-systems.jsonl:
    get:
      tags: [Bulk]
      summary: CitationSystem dump — NDJSON
      operationId: getCitationSystemsDump
      responses:
        '200':
          description:
            Newline-delimited JSON, one CitationSystem record object per
            line, with no `@context`.
          content:
            application/octet-stream:
              schema: { type: string }
  /dump/references.jsonl:
    get:
      tags: [Bulk]
      summary: CanonicalReference dump — NDJSON
      operationId: getReferencesDump
      responses:
        '200':
          description:
            Newline-delimited JSON, one CanonicalReference record object
            per line, with no `@context`.
          content:
            application/octet-stream:
              schema: { type: string }
  /dump/mappings.jsonl:
    get:
      tags: [Bulk]
      summary: MappingAssertion dump — NDJSON
      operationId: getMappingsDump
      responses:
        '200':
          description:
            Newline-delimited JSON, one MappingAssertion record object per
            line, with no `@context`.
          content:
            application/octet-stream:
              schema: { type: string }
  /dump/aliases.json:
    get:
      tags: [Bulk]
      summary: Alias table — JSON
      operationId: getAliasesDump
      description:
        The complete alias table as one JSON object, with sorted keys and no
        indentation. For a single lookup, use
        `/reg/work/{key}/aliases.json` instead. This file serves a consumer
        that mirrors the whole table.
      responses:
        '200':
          description: One JSON object. Each key is an alias. Each value is the
            canonical IRI that the alias resolves to.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AliasTable' }
  /dump/datapackage.json:
    get:
      tags: [Bulk]
      summary: Frictionless data-package descriptor
      operationId: getDataPackage
      responses:
        '200':
          description:
            The Frictionless data-package descriptor that lists the five
            resources — the four JSONL files and the JSON alias table —
            with, for each, its byte count and its `sha256:` hash, plus the
            registry version.
          content:
            application/json:
              schema: { type: object }
components:
  parameters:
    FlatKey:
      in: path
      name: key
      required: true
      schema:
        type: string
        pattern: '^[a-z0-9][a-z0-9._-]*$'
      description: Flat registry key.
    Uuid:
      in: path
      name: uuid
      required: true
      schema:
        type: string
        pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
  responses:
    NotFound:
      description: |
        No registry object found at this identifier. The site is static, so the
        host cannot build a per-path error body. It serves the site's own 404
        page instead: `text/html`, with the same navigation as every other
        page. A client detects the miss from the status code, not from the body.
      content:
        text/html:
          schema:
            type: string
            description: The site's HTML 404 page.
  schemas:
    FlatKey:
      type: string
      pattern: '^[a-z0-9][a-z0-9._-]*$'
      description: 'Flat registry key: lowercase, starts with [a-z0-9], allows . _ -'
    Iri:
      type: string
      format: iri
      description: >-
        An IRI (RFC 3987), not a URI (RFC 3986). The registry publishes
        resolver targets that carry non-ASCII characters, such as
        `…/Investigaciones_filosóficas_(edición_A)`. RFC 3986 forbids those
        characters, so a validator that enforces the `uri` format rejects
        records this site serves. The generated JSON Schema declares `iri`
        for the same reason.
    IsoDate:
      type: string
      pattern: '^\d{4}-\d{2}-\d{2}$'
    Status:
      type: string
      enum: [draft, active, deprecated, withdrawn, blocked]
    AdminMetadata:
      type: object
      required: [status, created, modified]
      properties:
        status: { $ref: '#/components/schemas/Status' }
        created: { $ref: '#/components/schemas/IsoDate' }
        modified: { $ref: '#/components/schemas/IsoDate' }
        superseded_by:
          allOf:
            - $ref: '#/components/schemas/Iri'
          description:
            Successor IRI. Only present when status is deprecated, withdrawn,
            or blocked.
    Creator:
      description:
        Discriminated on `kind`. A `person` entry names a family name (and
        optional given name); mononyms such as Plato use `family` alone. A
        `literal` entry is the escape hatch for pseudonymous, collective, or
        institutional authorship.
      oneOf:
        - type: object
          required: [kind, family]
          properties:
            kind: { type: string, enum: [person] }
            family: { type: string, minLength: 1 }
            given: { type: string, minLength: 1 }
        - type: object
          required: [kind, name]
          properties:
            kind: { type: string, enum: [literal] }
            name: { type: string, minLength: 1 }
    Work:
      allOf:
        - $ref: '#/components/schemas/AdminMetadata'
        - type: object
          required:
            [id, key, type, preferred_label, preferred_citation_system_key]
          properties:
            id:
              type: string
              pattern: '^https://textrefs\.org/id/work/[^/]+$'
            key: { $ref: '#/components/schemas/FlatKey' }
            type: { type: string, enum: [Work] }
            preferred_label: { type: string, minLength: 1 }
            alternative_labels:
              type: array
              minItems: 1
              uniqueItems: true
              items: { type: string, minLength: 1 }
              description:
                Additional names for the work — abbreviations, translated
                titles, and established short forms. Search and display only.
                Identity-neutral, because no label is a UUID seed input, so a
                label change never moves an identifier. Unique within one work;
                two different works MAY share an alternative label.
            preferred_citation_system_key:
              allOf:
                - $ref: '#/components/schemas/FlatKey'
              description:
                Key of the CitationSystem this work is cited under by default.
                Governs the bare /cite/{work_key}/{locator} alias and default
                presentation only; it is identity-neutral and never affects how
                a fully qualified reference validates or resolves.
            creators:
              type: array
              items: { $ref: '#/components/schemas/Creator' }
            alternateOf:
              type: array
              items: { $ref: '#/components/schemas/Iri' }
              description:
                Compiler-derived from the work's alternateOf MappingAssertions
                that are not deprecated, withdrawn or blocked. The array enriches
                the work and makes no review claim. Read the MappingAssertion for
                status. Read-only, never authored.
            isReferencedBy:
              type: array
              items: { $ref: '#/components/schemas/Iri' }
              description: Compiler-derived from the work's isReferencedBy
                MappingAssertions that are not deprecated, withdrawn or blocked.
                The array enriches the work and makes no review claim. Read the
                MappingAssertion for status. Read-only, never authored.
    CitationSystem:
      allOf:
        - $ref: '#/components/schemas/AdminMetadata'
        - type: object
          required: [id, key, type, preferred_label, description, locator_regex]
          properties:
            id:
              type: string
              pattern: '^https://textrefs\.org/id/system/[^/]+$'
            key: { $ref: '#/components/schemas/FlatKey' }
            type: { type: string, enum: [CitationSystem] }
            preferred_label: { type: string, minLength: 1 }
            description: { type: string, minLength: 1 }
            locator_regex: { type: string, minLength: 1 }
    ResolverTargetEntry:
      type: object
      required: [url, access]
      properties:
        url: { $ref: '#/components/schemas/Iri' }
        language:
          type: string
          minLength: 2
          pattern: '^(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4,8})(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|\d{3}))?(?:-(?:[\dA-Za-z]{5,8}|\d[\dA-Za-z]{3}))*(?:-[\dA-WY-Za-wy-z](?:-[\dA-Za-z]{2,8})+)*(?:-[Xx](?:-[\dA-Za-z]{1,8})+)?$|^[Xx](?:-[\dA-Za-z]{1,8})+$'
        edition: { type: string }
        provider: { type: string }
        access:
          type: string
          enum: [open, paywalled, restricted, unknown]
        license: { type: string }
        license_url:
          oneOf:
            - { $ref: '#/components/schemas/Iri' }
            - { type: 'null' }
        last_checked: { $ref: '#/components/schemas/IsoDate' }
    CanonicalReference:
      allOf:
        - $ref: '#/components/schemas/AdminMetadata'
        - type: object
          required:
            [id, type, work_key, citation_system_key, locator, resolver_targets]
          properties:
            id:
              type: string
              pattern: '^https://textrefs\.org/id/ref/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
            type: { type: string, enum: [CanonicalReference] }
            work_key: { $ref: '#/components/schemas/FlatKey' }
            citation_system_key: { $ref: '#/components/schemas/FlatKey' }
            locator: { type: string, minLength: 1 }
            resolver_targets:
              type: array
              default: []
              items: { $ref: '#/components/schemas/ResolverTargetEntry' }
    MappingAssertion:
      allOf:
        - $ref: '#/components/schemas/AdminMetadata'
        - type: object
          required: [id, type, subject, relation, target, source]
          properties:
            id:
              type: string
              pattern: '^https://textrefs\.org/id/mapping/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
            type: { type: string, enum: [MappingAssertion] }
            subject:
              type: string
              pattern: '^https://textrefs\.org/id/work/[a-z0-9][a-z0-9._-]*$'
              description: MUST be a Work IRI.
            relation:
              type: string
              enum: [alternateOf, isReferencedBy]
              description: Chosen by what the target is (ADR-0006). alternateOf
                (prov:alternateOf) — another entity denoting the same work.
                isReferencedBy (dcterms:isReferencedBy) — a document or page
                about the work.
            target:
              type: object
              required: [identifier]
              properties:
                identifier: { $ref: '#/components/schemas/Iri' }
                conforms_to:
                  description:
                    Informative. IRI (or array of IRIs) of the specification the
                    target conforms to. Validators MUST NOT key behaviour off it.
                  oneOf:
                    - $ref: '#/components/schemas/Iri'
                    - type: array
                      items: { $ref: '#/components/schemas/Iri' }
                      minItems: 1
            source: { type: string, minLength: 1 }
    WorkCollection:
      description: The `/reg/works.json` registry-wide collection of Work records.
      type: object
      required: ['@context', '@graph']
      properties:
        '@context': { type: string, format: uri }
        '@graph':
          type: array
          items: { $ref: '#/components/schemas/Work' }
    SystemCollection:
      description:
        The `/reg/systems.json` registry-wide collection of CitationSystem
        records.
      type: object
      required: ['@context', '@graph']
      properties:
        '@context': { type: string, format: uri }
        '@graph':
          type: array
          items: { $ref: '#/components/schemas/CitationSystem' }
    WorkAliasIndex:
      description:
        The `/reg/work/{key}/aliases.json` locator index of one work. Plain
        JSON, not JSON-LD; it carries no `@context`. The index states
        identity alone. Read the CanonicalReference for the status and the
        resolver targets.
      type: object
      required: [work_key, preferred_citation_system_key, refs]
      properties:
        work_key: { $ref: '#/components/schemas/FlatKey' }
        preferred_citation_system_key:
          allOf:
            - $ref: '#/components/schemas/FlatKey'
          description:
            The system that the bare `/cite/{work_key}/{locator}` alias uses
            (ADR-0005). A default for presentation, never an identity claim.
            The other keys of `refs` have equal standing.
        refs:
          type: object
          description: One entry per citation system key that this work carries
            references under. An empty object means the work has no
            references yet. The same locator under two systems denotes a
            different passage, and a different UUID, each time.
          additionalProperties:
            type: object
            description: Locator to CanonicalReference UUID.
            additionalProperties:
              type: string
              pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
    AliasTable:
      description:
        The `/dump/aliases.json` table, with one entry per alias. A key is
        either a `/cite/` alias path — `{work_key}/{locator}` or
        `{work_key}/{citation_system_key}/{locator}` — or an external
        identifier IRI carried by a MappingAssertion. A value is the
        canonical IRI that the key resolves to — an `/id/ref/` IRI for a
        `/cite/` alias, an `/id/work/` IRI for an external identifier. A
        `://` in the key marks the second kind.
      type: object
      additionalProperties: { $ref: '#/components/schemas/Iri' }
    # Referenced by no response, and published on purpose: it names the union
    # that every `/dump/` line and every `@graph` member belongs to, so a
    # client can validate a record without knowing its type first. It mirrors
    # the `RegistryObject` union in `standard/schema/index.ts`.
    RegistryObject:
      oneOf:
        - { $ref: '#/components/schemas/Work' }
        - { $ref: '#/components/schemas/CitationSystem' }
        - { $ref: '#/components/schemas/CanonicalReference' }
        - { $ref: '#/components/schemas/MappingAssertion' }
      discriminator:
        propertyName: type
        mapping:
          Work: '#/components/schemas/Work'
          CitationSystem: '#/components/schemas/CitationSystem'
          CanonicalReference: '#/components/schemas/CanonicalReference'
          MappingAssertion: '#/components/schemas/MappingAssertion'
    # Reserved, and deliberately unreferenced. No response uses this shape
    # today, because a static host serves an HTML 404 page and nothing else.
    # It describes the error body of the dynamic resolver that `info.description`
    # announces — the `Accept-Language` / `edition` 303 on `/id/ref/{uuid}` —
    # and it stays here so that contract and implementation land together.
    Error:
      type: object
      required: [message]
      properties:
        message: { type: string }
        code: { type: string }
