OpenProposal 3.0 JSON Schema Unofficial

How it's built

Postindustria, an IAB Tech Lab member, generates these schemas from the OpenProposal 3.0 specification text. Nobody writes a schema file by hand. Every schema, rule, example payload, and SDK type is a function of two things: the specification at a pinned commit, and a mapping and a set of decision records that we review by hand. This page explains each step, and what it shapes in the type map and the downloads.

The pipeline

The pipeline runs in six steps. Steps 1, 4, and 5 are programs: two runs write the same bytes, and the gates fail when the committed output differs from a fresh run. Step 2 and the decision records are reviewed input. Step 3 is both: mapping rules derive what the text decides, and a decision record covers everything else. Step 6 checks the result against the text.

The pipeline: the specification is extracted into an intermediate representation, mapped with the value domains and the decision records, resolved into a model with the constructors and the variant switches, and emitted as the schemas, rules, SDK types, and examples that the site and the downloads serve. A verification step checks every output against the extracted text. Specificationmarkdown at a pinnedcommit and blob1 ExtractIR: each element withan ID, a quote, a digest3 Mapone entry per element,by rule or by decision4 Resolvethe model: layers,modules, named copies5 EmitJSON Schema, rules,SDK types, examplesSite and downloadsthe type map, thefiles at their $id2 Value domainsconcepts, primitives,reviewed firstDecision recordsgaps and departures,each quoting its linesConstructors andthe as-writtenswitches6 VerifyThe fidelity oracle reads the IR alone and checks every output. Every gate rejects its known-bad fixtures,and the specification's examples run against the schemas and the rules. generatedreviewed by handchecks every output The pipeline: the specification is extracted into an intermediate representation, mapped with the value domains and the decision records, resolved into a model with the constructors and the variant switches, and emitted as the schemas, rules, SDK types, and examples that the site and the downloads serve. A verification step checks every output against the extracted text. Specificationmarkdown at a pinnedcommit and blob1 ExtractIR: each element withan ID, a quote, a digest3 Mapone entry per element,by rule or by decision4 Resolvethe model: layers,modules, named copies5 EmitJSON Schema, rules,SDK types, examplesSite and downloadsthe type map, thefiles at their $id2 Value domainsconcepts, primitives,reviewed firstDecision recordsgaps and departures,each quoting its linesConstructors andthe as-writtenswitches6 VerifyThe fidelity oracle reads the IR aloneand checks every output. Every gaterejects its known-bad fixtures, and thespecification's examples run againstthe schemas and the rules. generatedreviewed by hand
Each step is a function of the steps before it and of the inputs we review by hand. The verification step reads the extracted text, not the mapping, so it can disagree with every step in between.

Extraction

A deterministic parser reads the specification's markdown and writes an intermediate representation (IR). It makes no judgments and calls no language model. On main, the IR holds 71 field rows, 16 key rows, 23 value lists, 19 inline objects, and 33 RFC 2119 statements, with the marker rows, the code blocks, and every prose sentence that names an identifier.

Each element has a stable ID, a quote, and a digest.

  • The ID says where the element is, in the order you would look for it. Read field:5.10:billing#description/object:1 as: a field row (field), in § 5.10 (5.10), the row whose name cell is billing, its description cell (#description), and the first inline object in that cell (/object:1). A key row is key:5.6.1:commitment.flight_start, a value list adds /values:1, and a member's value list adds the member, as in …/object:1/feed_type. A statement or a sentence has no name, so its ID ends in the first eight hex digits of the SHA-256 of its text, such as rule:5.5:2eab2842.
  • The quote is the element's text as the specification writes it.
  • The digest is the SHA-256 of a table row's source bytes, or of any other element's canonical JSON without its location, so moving an element doesn't change it.

Every mapping entry, decision record, domain concept, primitive, and test vector that rests on an element stores the element's digest. When the text of the element changes, each of them goes stale, and the gates fail until someone reviews it and records the new digest.

Where you see it. In the type map, a type's Specification lines it rests on lists each element it rests on, with a readable label, the quote, and a link to its lines on GitHub. Expand an item to see its ID.

The value-domain model

Before any mapping, we wrote and reviewed a model of the values the specification talks about: 10 value domains, such as identity, money, and time, with 22 concepts under them. Each concept says what a value means, names the one type that represents it, and cites the specification elements and standards it rests on. The kernel defines each of its 21 primitives once, by its meaning, not by its format.

A concept gets a type of its own only when its values validate differently, or a receiver handles them differently: compares them, adds them, fetches them, or shows them. Where a value comes from doesn't change either, so it goes in the field's description.

Money and its currency
A sum of money is Amount, a bare number of at least 0. Its currency is the CurrencyCode in a sibling currency member of the same object, never one inherited from an enclosing object. Where the money is required, so is its currency. Where it is optional, dependentRequired requires the currency with it. The one signed case, an option's price change, is PriceAdjustment (D-036).
Three kinds of time
A point in time is a Timestamp, an RFC 3339 date-time (D-001). A span is a Duration, whatever form the examples write (D-040). A daypart window is a DaypartWindow: days and two TimeOfDay ends, HH:MM, read on the line item's clock. The open PRs add that clock as time_zone, a TimeZone; on main the text names none (D-004, D-038). Each validates differently, and a receiver compares the first two as instants and lengths, and reads the third on a clock.
Uuid or ExternalId
An ID that a party of the proposal mints, such as a proposal_id, is a Uuid, so every party compares it the same way (D-037). As written, the text types such an ID as any string. An ID, a code, or a version that another system or list defines is an ExternalId: the proposal carries it as written and never parses it, and the field's description names the system. An ExternalId and a Text have the same structure, a string, but a receiver compares one and shows the other, so the domain model declares them distinct types.
Named vocabularies
A field whose values are terms has a named vocabulary, such as Channel. A closed vocabulary is an enum. An open one also takes a NamespacedTerm, <domain>:<term>, such as examplepub.example:podcast_host_read, where the domain is the definer's. A vocabulary whose core terms the specification doesn't list yet takes namespaced terms only (D-041).

Where you see it. The type map's first column groups the primitives by value domain, and a primitive's detail shows its domain, its concept, and the concept's meaning.

Semantic mapping

The mapping says what each IR element becomes: a property, a named type, a vocabulary term, a rule, an obligation, a profile, an example, or informative text. Every element has exactly one entry on each input, in the file of the module that owns its section. Of the 541 entries across both inputs, 364 come from mapping rules and 177 cite a decision record.

  • Rules first. A mapping rule maps what the IR, the domain model, and the type rules decide. For example, a row a concept cites is typed by that concept, by the rule domain-row, so no decision types it. An entry can't cite a decision where a rule applies.
  • Gaps. Where the text leaves a point open, a decision record settles it. There are 39 such records, of kind gap. Both variants keep them, since the text gives nothing to fall back to.
  • Departures. Where the text or its examples settle a point and we would write it differently, a decision record of kind departure records the change, and lists which constraint forms it changes. There are 6, and the as-written variant switches each one off.

A decision record states its question, the options we weighed, the one we chose and why, what we ask the specification to state, and the elements it rests on, each quoted and digested. Read them all in the decision records.

Where you see it. A type's detail lists its Decision records. A member that differs between the variants carries a badge that links the departure, and a bar under a type's chip marks a type a proposal changes.

Constructors and copies

The specification writes a few forms many times. The model holds each one once, as a template: Selectable<T, K> for a field the buyer selects options of (§ 3.1), Settable<T> for a field the buyer sets within bounds (§ 3.2), Range<T> for a { min, max } pair, CatalogRefOr<T> for an inline value or a catalog reference (§ 2.2), and List<T> and Map<K, V>.

Templates exist only in the model. Each application becomes a named, concrete copy in the schema, so a plain code generator that knows nothing about generics still produces correct classes. A gate runs a stock generator on the published schema files alone and holds its classes to the same verdicts as ours.

A copy is named after the field its mapping entry names, in PascalCase and with no singular form: <Field>Selection, <Field>Bounds, or <Field>Range. So formats gives FormatsSelection, and max in frequency_cap's object gives MaxRange. A CatalogRefOr copy can stand where no field names it, so it is named after its argument, <T>OrCatalogRef. List and Map carry no rule of their own, so they stay inline. Two fields that apply one template to the same arguments, such as GeoBounds and DeviceBounds, get two names for one definition, which the template keeps from drifting apart.

Where you see it. In the type map, a template is in italics in the second column, and a copy has a dashed border. A copy's detail shows its application, such as Selectable<T=FormatId, K=FormatId>. On main, as written, the model holds 19 copies.

Layers

Types build from the bottom up, and a type uses only the layers below it.

  1. Primitives. Every scalar that validates or is handled differently, such as Uuid and Amount.
  2. Constructors and parts. The templates, and the parts their copies share, such as PriceDelta. A copy sits at the layer of the highest type it holds.
  3. Vocabularies. Every value list a module owns, such as Channel.
  4. Objects and unions. Each module's structures, such as Commitment.
  5. Documents and profiles. Proposal, LineItem, and the summaries projected from them.

The layering is a gate, and it matters for three reasons. No reference chain can form a cycle, so every generator can emit the types in order. Each concept is defined once, low in the stack, and every use references it, so a constraint can't drift between two copies. And the modules follow the specification's sections, each depending only on the kernel and on the modules it declares, which gives the schema files their structure: kernel/1/kernel.json, modules/<module>/1/<module>.json, and documents/1/<document>.json.

Where you see it. The type map's five columns are the layers. Select a type to see what it's built from and what uses it.

Emission rules

The emitter turns the model into JSON Schema 2020-12. The schemas use standard keywords only, so any validator reads them the same way.

  • No custom keywords: no x- keys, no discriminator, and no $dynamicRef.
  • oneOf only for a tagged union, where every branch has a required const member that names it.
  • anyOf for an open vocabulary, and for the untagged unions the draft requires, such as the choice between an inline value and a catalog_ref. Our feedback lists each untagged union as a place where the specification should add a tag.
  • if / then for a condition, such as a currency that is required when basis is spend, and dependentRequired for a member another member requires.
  • No allOf: objects are named and referenced whole, so no schema inherits from another.
  • A constraint lives only on a named type, and a property carries nothing beside its $ref but a description.
  • An object is closed unless the specification leaves its structure open.

A profile, such as the § 2.1 summary of a line item, projects paths, not whole fields. A path through an object makes a named object with only the members the paths name, such as LineItemSummaryPricingEntry.

Where you see it. A type's Schema view shows its emitted schema, and each $ref opens the type it names. The downloads serve each file at the URL its $id names.

What JSON Schema can't say

Some statements can't be checked by a schema of one payload: one field compared with another, an entry that must name an option in another list, uniqueness by a key, arithmetic, which party may change a field between two versions, and what a receiver does. Each one becomes a rule, with an ID, the text it comes from, the object that owns it, and at least one valid and one invalid test vector. A vector is a payload, a pair of versions with the party that made the change, or a payload with the receiver's response. An invalid vector of a SHOULD rule is a warning, and of any other rule an error. On main, as written, there are 17 rules.

A requirement that no payload can show, such as a seller rejecting a stale commitment attempt, is an obligation on a party: the seller, the buyer, a consumer, or an implementation. An obligation has no keyword and no vector. The model lists 11 of them.

Where you see it. A type's detail lists its Rules beyond the schema. The downloads include each build's rules with their vectors.

Verification

  • The fidelity oracle. A separate program reads, from the IR alone, every constraint the text states in a form that needs no judgment: a REQUIRED or OPTIONAL row, a conditional REQUIRED, a value list, a list of members a representation must contain, a stated type, and a YAML example. It never reads the mapping or the decisions. For each constraint it builds a valid and an invalid payload and checks that the emitted schema gives each its verdict. A disagreement fails, unless a departure covers that exact element and lists that form of constraint.
  • Known-bad fixtures. Every gate has fixtures it must reject, such as a model with a currency deleted, a twin of Amount, or a link to a page the site doesn't hold. After the gates run, CI reruns each gate on each fixture, and the gate must fail with the expected message.
  • Append-only gate records. Each gate has a fingerprint of its code, its fixtures, and its exemptions. When a fingerprint changes, a new gate decision must accept it and say why. CI fails when a commit edits, deletes, or renames a gate decision, so a gate can't be relaxed in the change that needs it relaxed.
  • The specification's examples. Every example the specification gives runs against its document's schema and every rule one payload decides. A failure is recorded with the decision its location rests on, and goes into our implementation feedback. The model never changes to fit an example.
  • Determinism and parity. Every generated file is written twice in separate processes and compared byte for byte. The TypeScript, Python, and Go types reach the reference validator's verdict on every case of the conformance corpus.

Variants and inputs

Two switches pick one of four builds. Each has its own data in the type map, its own examples, and its own downloads.

  • Input. main is the specification on its main branch at commit 44dfaaf. With the open PRs is main with the pull requests that were open on the day we locked merged in, each at its head that day. We merged them ourselves, so that tree exists on no upstream branch.
  • Variant. As written switches our 6 departures off, so each field they change takes the form the text or its examples write. With our proposals switches them on. In both, the 39 gap records stay in force. A gate checks that the two builds differ only where a switch says.

One field, end to end

This follows the line item's formats field through each step, on main, as written. Each step links the type map at what it produced.

  1. The specification row

    § 5.4, the formats row, L242 at 44dfaaf:

    formats | Object | selectable | available[] of registry format ids namespaced by channel (display/300x250, audio/host-read-30s), included[], pricing, max_select

    Its marker, selectable, says the buyer selects among options the seller declares. See the row in LineItem's composition.

  2. The element

    The parser records the row as field:5.4:formats, a field row in § 5.4 named formats, with the digest 2a8a55ef5666b057f31306cadf667fc01dff13e5cc5e009f00d3f649de375016. Everything below that rests on the row stores that digest.

  3. The concept

    The row's options are "registry format ids namespaced by channel", written display/300x250, so they validate differently from an external ID: 300x250 isn't one. The domain model gives them the concept format-id, in the identity domain, and its type FormatId, the pattern ^[^\x00-\x20\x7f/]+/[^\x00-\x20\x7f/]+$ (D-019).

  4. The constructor

    A selectable field takes the § 3.1 form, Selectable<T, K>. The mapping entry applies it as Selectable<FormatId, FormatId>: an option is named in included[] and the pricing keys by its value, so K is T (D-007), and no field takes a bare value in place of the full form (D-018).

  5. The copy

    The model names the application after its field: FormatsSelection, in layer 2 because it holds only primitives and a part, and in the creative module, which owns § 5.4. It carries the template's 2 rules, such as constructor:Selectable:options-named: each included[] entry and each pricing key names an option in available[].

  6. The emitted schema

    In schemas/as-written/modules/creative/1/creative.json, under $defs. Open it in the type map.

    {
      "additionalProperties": false,
      "description": "A field the buyer sets by choosing options from the set the seller declares. `included[]` names the options in the base rate, and `pricing` gives the change in price for each other option.",
      "properties": {
        "available": {
          "items": {
            "$ref": "../../../kernel/1/kernel.json#/$defs/FormatId"
          },
          "type": "array"
        },
        "included": {
          "description": "The options in the base rate, each named as `available[]` holds it: a string option by its value, and an object option by its `id`, the identity the seller mints for it.",
          "items": {
            "$ref": "../../../kernel/1/kernel.json#/$defs/FormatId"
          },
          "type": "array"
        },
        "max_select": {
          "$ref": "../../../kernel/1/kernel.json#/$defs/Count"
        },
        "pricing": {
          "additionalProperties": {
            "$ref": "../../../kernel/1/kernel.json#/$defs/PriceDelta"
          },
          "description": "The change in price of each option beyond those included, keyed by the option's name: a string option's value, or an object option's `id`, the identity the seller mints for it.",
          "propertyNames": {
            "$ref": "../../../kernel/1/kernel.json#/$defs/FormatId"
          },
          "type": "object"
        }
      },
      "required": [
        "available",
        "included"
      ],
      "type": "object"
    }
  7. The example value

    The type examples take each value from the specification's own examples where the schema accepts it, here examples/line-item-display.yaml. See it in LineItem's example.

    {
      "formats": {
        "available": [
          "display/300x250",
          "display/728x90"
        ],
        "included": [
          "display/300x250"
        ],
        "pricing": {
          "display/728x90": {
            "delta_cpm": 3
          }
        },
        "max_select": 2
      }
    }

How to reproduce it

The tooling is public at KonstantinMirin/openproposal-schema. It needs Node 22, and the gates also need Python 3.12 and Go 1.25 or later. To run every gate, as CI does:

npm ci --ignore-scripts
npm run setup:oracle
npm run setup:sdk
npm run setup:plaingen
npm run ci

Each step also runs on its own: npm run extract, map, resolve, emit, rules, type-examples, sdk, fidelity, and site, which builds this site into site/. The repository's README describes each command and each gate.