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.
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:1as: a field row (field), in § 5.10 (5.10), the row whose name cell isbilling, its description cell (#description), and the first inline object in that cell (/object:1). A key row iskey: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 asrule: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 theCurrencyCodein a siblingcurrencymember of the same object, never one inherited from an enclosing object. Where the money is required, so is its currency. Where it is optional,dependentRequiredrequires the currency with it. The one signed case, an option's price change, isPriceAdjustment(D-036). - Three kinds of time
- A point in time is a
Timestamp, an RFC 3339 date-time (D-001). A span is aDuration, whatever form the examples write (D-040). A daypart window is aDaypartWindow: days and twoTimeOfDayends,HH:MM, read on the line item's clock. The open PRs add that clock astime_zone, aTimeZone; onmainthe 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. UuidorExternalId- An ID that a party of the proposal mints, such as a
proposal_id, is aUuid, 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 anExternalId: the proposal carries it as written and never parses it, and the field's description names the system. AnExternalIdand aTexthave 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 anenum. An open one also takes aNamespacedTerm,<domain>:<term>, such asexamplepub.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.
- Primitives. Every scalar that validates or is handled differently, such as
UuidandAmount. - 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. - Vocabularies. Every value list a module owns, such as
Channel. - Objects and unions. Each module's structures, such as
Commitment. - 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, nodiscriminator, and no$dynamicRef. oneOfonly for a tagged union, where every branch has a requiredconstmember that names it.anyOffor an open vocabulary, and for the untagged unions the draft requires, such as the choice between an inline value and acatalog_ref. Our feedback lists each untagged union as a place where the specification should add a tag.if/thenfor a condition, such as a currency that is required whenbasisisspend, anddependentRequiredfor 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
$refbut 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.
mainis the specification on itsmainbranch at commit44dfaaf. With the open PRs ismainwith 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.
The specification row
§ 5.4, the
formatsrow, L242 at 44dfaaf:formats| Object | selectable |available[]of registry format ids namespaced by channel (display/300x250,audio/host-read-30s),included[],pricing,max_selectIts marker,
selectable, says the buyer selects among options the seller declares. See the row inLineItem's composition.The element
The parser records the row as
field:5.4:formats, a field row in § 5.4 namedformats, with the digest2a8a55ef5666b057f31306cadf667fc01dff13e5cc5e009f00d3f649de375016. Everything below that rests on the row stores that digest.The concept
The row's options are "registry format ids namespaced by channel", written
display/300x250, so they validate differently from an external ID:300x250isn't one. The domain model gives them the conceptformat-id, in the identity domain, and its typeFormatId, the pattern^[^\x00-\x20\x7f/]+/[^\x00-\x20\x7f/]+$(D-019).The constructor
A
selectablefield takes the § 3.1 form,Selectable<T, K>. The mapping entry applies it asSelectable<FormatId, FormatId>: an option is named inincluded[]and thepricingkeys by its value, soKisT(D-007), and no field takes a bare value in place of the full form (D-018).The copy
The model names the application after its field:
FormatsSelection, in layer 2 because it holds only primitives and a part, and in thecreativemodule, which owns § 5.4. It carries the template's 2 rules, such asconstructor:Selectable:options-named: eachincluded[]entry and eachpricingkey names an option inavailable[].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" }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 inLineItem'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.