diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1ea9da9..a133905 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -67,3 +67,14 @@ jobs: python-version: "3.12" - name: Check every field in the body appears in Appendix A run: python3 scripts/check_appendix.py + + examples-match-spec: + name: Examples match the tables + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install pyyaml + - run: python3 scripts/check_examples.py diff --git a/README.md b/README.md index 8314672..6deafd8 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ server. | :--- | :--- | | **Current draft** | [`spec/openproposal-3.0.md`](spec/openproposal-3.0.md) | | **Comment period opens** | 2026-09-22 | -| **Comment period closes** | 2026-10-22 | +| **Comment period closes** | 2026-11-10 | | **Announcement** | [IAB Tech Lab introduces AAMP 3.0 with OpenProposal](https://iabtechlab.com/press-releases/iab-tech-lab-introduces-aamp-3-0-with-openproposal/) | | **Short link** | [iabtechlab.com/AAMPv3](https://iabtechlab.com/AAMPv3) (points to this repository) | | **How to comment** | [CONTRIBUTING.md](CONTRIBUTING.md) | @@ -56,7 +56,7 @@ Pick the route that matches what you have to say: | :--- | :--- | | Flag something unclear, wrong, or missing | [Open a **Comment**](https://github.com/IABTechLab/OpenProposal/issues/new?template=comment.yml) | | Propose specific normative wording | [Open a **Change request**](https://github.com/IABTechLab/OpenProposal/issues/new?template=change-request.yml), or send a pull request against `spec/openproposal-3.0.md` | -| Answer one of the 7 open questions | Comment on that question's [tracking issue](docs/open-questions.md) | +| Answer one of the open questions | Comment on that question's [tracking issue](docs/open-questions.md) | | Report what happened when you tried to implement it | [Open **Implementation feedback**](https://github.com/IABTechLab/OpenProposal/issues/new?template=implementation-feedback.yml) | | Ask a question rather than raise an issue | Use [Discussions](https://github.com/IABTechLab/OpenProposal/discussions) | @@ -69,15 +69,15 @@ in [docs/disposition-log.md](docs/disposition-log.md). See Three things to understand before the field tables make sense: -1. **No inheritance.** Every line item is fully self-contained. A field never +1. **No inheritance.** Every term of a line item is on the line item. A field never takes its value from the parent proposal or a sibling line item. 2. **Every field carries exactly one mutability marker** (§ 3) — `immutable`, - `seller-set`, `selectable`, `settable`, `supplied`, `requestable` or - `derived`. The marker is what tells a buying agent whether it can act - unilaterally. -3. **Composition is constraint satisfaction, not negotiation.** Of 71 fields, - only 3 require a round trip with the counterparty. That ratio is the design - claim the draft is asking you to test. + `seller-set`, `selectable`, `settable`, `supplied`, `requestable`, `derived`, + `lifecycle`, `recorded`, `asserted` or `mixed`. The marker is what tells a + buying agent whether it can act unilaterally. +3. **Composition is constraint satisfaction, not negotiation.** Only `requestable` + fields require a round trip with the counterparty to resolve. Appendix B has the + count. That ratio is the design claim the draft is asking you to test. ## Downstream standards @@ -89,8 +89,8 @@ OpenRTB 2.6). See § 1.1. Machine-readable schemas are coming. They are not published with this first draft, because the comment period is still deciding questions that shape -them. They will follow the first revision, after the comment period closes on -2026-10-22. Until then, [`examples/`](examples/) is the best reference for +them. They will follow the first revision, after the comment period closes. +Until then, [`examples/`](examples/) is the best reference for shape. See [schema/README.md](schema/README.md). ## Contributors and governance diff --git a/docs/comment-process.md b/docs/comment-process.md index ed2abe8..40e5d69 100644 --- a/docs/comment-process.md +++ b/docs/comment-process.md @@ -8,14 +8,14 @@ or a documented decision not to change it. | Phase | Dates | What happens | | :--- | :--- | :--- | | **1. Comment period opens** | 2026-09-22 | Draft frozen at a tagged version. Announcement published. Issues open. | -| **2. Comment period** | 2026-09-22 → 2026-10-22 (30 days) | Anyone may file. Maintainers triage and label within 5 business days of filing, but do not resolve. | -| **3. Comment period closes** | 2026-10-22 | No new comments enter the formal record. Later issues are labelled `post-comment` and considered for the following draft. | +| **2. Comment period** | 2026-09-22 → 2026-11-10 | Anyone may file. Maintainers triage and label within 5 business days of filing, but do not resolve. | +| **3. Comment period closes** | 2026-11-10 | No new comments enter the formal record. Later issues are labelled `post-comment` and considered for the following draft. | | **4. Adjudication** | `TBD` → `TBD` | Working group works the queue. Every open comment gets a disposition. | | **5. Disposition published** | `TBD` | [disposition-log.md](disposition-log.md) published in full. Commenters notified on their issue. | | **6. Revised draft** | `TBD` | New draft tagged. Either ratification or a second comment period, depending on how much moved. | > **Maintainers: phases 4–6 are still `TBD`.** Fill them before the comment -> period closes on 2026-10-22. +> period closes. ## Dispositions @@ -59,7 +59,7 @@ Triage labels, applied on filing: | :--- | :--- | | `comment` | General comment on the draft | | `change-request` | Proposes specific normative wording | -| `open-question` | Relates to one of the 7 questions in § 8 | +| `open-question` | Relates to one of the questions in § 8 | | `implementation-feedback` | From someone who built against the draft | | `editorial` | Typo, formatting, broken link — no normative effect | | `normative` | Changes what the spec requires | diff --git a/docs/disposition-log.md b/docs/disposition-log.md index 52ec2eb..639c89c 100644 --- a/docs/disposition-log.md +++ b/docs/disposition-log.md @@ -3,7 +3,7 @@ The formal record of every comment filed during the OpenProposal 3.0 public comment period, and what was decided. -**Status: comment period open (2026-09-22 → 2026-10-22). No dispositions yet.** +**Status: comment period open (2026-09-22 → 2026-11-10). No dispositions yet.** This file is updated as comments are adjudicated and published in full at the end of the period. See [comment-process.md](comment-process.md) for what each diff --git a/docs/open-questions.md b/docs/open-questions.md index d8d32ba..c451d9e 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -24,13 +24,14 @@ whether resolving inside one is worth the modelling cost. --- -### OQ-2 — Do seller catalogs (`catalog_ref`) belong in 3.0? +### OQ-2 — Is a fetchable reference to a seller catalog needed? -**Issue:** [#3](https://github.com/IABTechLab/OpenProposal/issues/3) · **Spec:** § 2.2 +**Issue:** [#3](https://github.com/IABTechLab/OpenProposal/issues/3) · **Spec:** § 2.1 -Or should the object stay fully inline until a later version? `catalog_ref` -buys compactness across a large shelf at the cost of a resolution step during -discovery and a versioning regime sellers must operate. +The object is fully inline. A seller reuses catalog entries in its own systems, and +a summary lists what § 2.1 names. A reference a buyer could fetch would buy +compactness across a large shelf at the cost of a fetch rule, a resolution step +during discovery, and a versioning regime sellers must operate. **What would settle it:** how large a real shelf gets, and whether sellers already maintain versioned, immutable catalog entries or would have to build @@ -65,26 +66,24 @@ proposed fallback behaviour. --- -### OQ-5 — Is a `min_line_items` field needed? +### OQ-5 — Do `linked_execution` and `requires` cover bundle purchases? **Issue:** [#6](https://github.com/IABTechLab/OpenProposal/issues/6) · **Spec:** § 2, § 4 -The draft makes proposals subsettable unless binding terms mandate linked -execution. Does the proposal need an explicit `min_line_items` to require a -bundle purchase, or is that left to buyer discretion and the binding terms? +The draft makes proposals subsettable unless `linked_execution` is `all`, and a line +item can state `requires`. Does the proposal also need a `min_line_items` field? -**What would settle it:** whether sellers actually merchandise bundles that must -be bought whole, and whether "binding terms" is a precise enough hook. +**What would settle it:** whether sellers merchandise a bundle these two fields +can't express. --- -### OQ-6 — Should `payment_terms` and `invoicing_party` be catalog references? +### OQ-6 — Should `payment_terms` and `invoicing_party` repeat on every line item? **Issue:** [#7](https://github.com/IABTechLab/OpenProposal/issues/7) · **Spec:** § 5.10 -Rather than repeated on every line item, given they are usually identical across -a seller's shelf. Note this is in tension with the no-inheritance rule (§ 2) — -a `catalog_ref` is explicitly not inheritance, but the ergonomics are similar. +They are usually identical across a seller's shelf. Note this is in tension with the +no-inheritance rule (§ 2): sharing them would need another mechanism. **What would settle it:** whether `billing` genuinely varies per line item in practice (different supplier of record, different invoicing entity per @@ -92,12 +91,12 @@ property). --- -### OQ-7 — `environments[]` needs validation against real inventory +### OQ-7 — `environments.available[]` needs validation against real inventory **Issue:** [#8](https://github.com/IABTechLab/OpenProposal/issues/8) · **Spec:** § 5.2 -The enum — `web_desktop`, `web_mobile`, `app_mobile`, `ctv`, `dooh`, -`audio_streaming` — is proposed, not derived from implementation experience. +The enum — `web_desktop`, `web_mobile`, `app_mobile`, `ctv`, `dooh`, each +defined in § 5.2.1 — is proposed, not derived from implementation experience. **What would settle it:** sellers mapping their real inventory onto the enum and reporting what does not fit. diff --git a/examples/line-item-audio.yaml b/examples/line-item-audio.yaml index 4ca2f21..66753e9 100644 --- a/examples/line-item-audio.yaml +++ b/examples/line-item-audio.yaml @@ -2,18 +2,19 @@ line_item_id: "streaming-audio" status: published channel: audio -environments: { available: ["audio_streaming"], included: ["audio_streaming"] } +environments: { available: ["web_mobile", "app_mobile"], + included: ["web_mobile", "app_mobile"] } formats: { available: ["audio/host-read-30s", "audio/produced-30s"], included: ["audio/produced-30s"], - pricing: { "audio/host-read-30s": { delta_cpm: 9.00 } } } + deltas: { "audio/host-read-30s": { delta_cpm: 9.00 } } } production: - available: [{ id: "seller_built", build_owner: seller, lead_time: "10 business days" }, - { id: "buyer_supplied", build_owner: buyer, lead_time: "0" }] + available: [{ id: "seller_built", build_owner: seller, lead_time: "P14D" }, + { id: "buyer_supplied", build_owner: buyer, lead_time: "P0D" }] included: ["buyer_supplied"] - pricing: { seller_built: { delta_flat: 12000.00 } } + deltas: { seller_built: { delta_flat: 12000.00 } } -frequency_cap: { max: { min: 1, max: 6 }, period: "day", basis: listener } +frequency_cap: { count: { min: 1, max: 6 }, period: day, per: listener } pricing: - { cost_method: CPM, gross_rate: 18.00, floor: 15.84, @@ -24,12 +25,12 @@ commitment: basis: spend amount: 60000.00 currency: USD - flight_start: "2026-10-06" - flight_end: "2026-12-21" + flight: { start: "2026-10-06T00:00:00Z", end: "2026-12-21T23:59:59Z" } idempotency_key: "agency-a-camp1-streaming-audio-a7c33e10" committed_metrics: [{ metric: completion_rate, value: 0.92, basis: seller_reported }] +visibility: public buying_route: SSP_PLATFORM -transaction_mechanism: { available: ["PREFERRED_DEAL"], included: ["PREFERRED_DEAL"] } +transaction_mechanism: { available: ["preferred_deal"], included: ["preferred_deal"] } serving_mode: dai correlation_id: "ep-q4-2026-commitment/streaming-audio" diff --git a/examples/line-item-ctv-bonus.yaml b/examples/line-item-ctv-bonus.yaml new file mode 100644 index 0000000..0df595e --- /dev/null +++ b/examples/line-item-ctv-bonus.yaml @@ -0,0 +1,29 @@ +# OpenProposal 3.0 — Example: Bonus line item (spec § 6.5) +# Illustrative only. A CTV bonus unlocked when the buyer commits to the display +# and CTV line items together, with at least 50,000 USD on display. Its price is +# its own: nothing is inherited from the line items it requires. +line_item_id: "weather-app-ctv-bonus" +status: published +channel: ctv +properties: { available: [{ id: "weather-app" }], included: ["weather-app"] } +environments: { available: ["ctv"], included: ["ctv"] } +formats: { available: ["ctv/instream-15s"], included: ["ctv/instream-15s"] } +creative_delivery: { method: seller_hosted, protocol: vast_4_2, secure_required: true } + +frequency_cap: { count: { min: 1, max: 2 }, period: day, per: household } + +requires: + all_of: ["business-daily-display", "weather-app-ctv"] + min_commitments: + - { line_item_id: "business-daily-display", basis: spend, amount: 50000.00, currency: USD } + +pricing: + - { cost_method: FLAT, gross_rate: 0.00, + price_valid_until: "2026-12-01T23:59:59Z", currency: USD } + +commitment_bounds: { max_impressions: 500000 } + +visibility: registry_gated # its requires names the gated display line item +buying_route: SSP_PLATFORM +transaction_mechanism: { available: ["private_auction"], included: ["private_auction"] } +serving_mode: ssai diff --git a/examples/line-item-ctv.yaml b/examples/line-item-ctv.yaml index fda3994..4ba8cb1 100644 --- a/examples/line-item-ctv.yaml +++ b/examples/line-item-ctv.yaml @@ -6,10 +6,10 @@ properties: { available: [{ id: "weather-app" }], included: ["weather-app"] environments: { available: ["ctv"], included: ["ctv"] } formats: { available: ["ctv/instream-15s", "ctv/instream-30s"], included: ["ctv/instream-15s"], - pricing: { "ctv/instream-30s": { delta_cpm: 8.00 } } } + deltas: { "ctv/instream-30s": { delta_cpm: 8.00 } } } creative_delivery: { method: seller_hosted, protocol: vast_4_2, secure_required: true } -frequency_cap: { max: { min: 1, max: 4 }, period: "day", basis: household } +frequency_cap: { count: { min: 1, max: 4 }, period: day, per: household } pricing: - { cost_method: CPM, gross_rate: 45.00, floor: 39.60, @@ -20,8 +20,7 @@ commitment: basis: spend amount: 160000.00 currency: USD - flight_start: "2026-10-06" - flight_end: "2026-12-21" + flight: { start: "2026-10-06T00:00:00Z", end: "2026-12-21T23:59:59Z" } idempotency_key: "agency-a-camp1-weather-app-ctv-3b7e0d52" hold_status: { hold_available: true, hold_duration: "P14D", @@ -29,7 +28,10 @@ hold_status: { hold_available: true, hold_duration: "P14D", hold_expires_at: "2026-10-01T00:00:00Z" } committed_metrics: [{ metric: completion_rate, value: 0.95, basis: seller_reported }] +visibility: public buying_route: SSP_PLATFORM -transaction_mechanism: { available: ["PRIVATE_AUCTION"], included: ["PRIVATE_AUCTION"] } +transaction_mechanism: { available: ["private_auction"], included: ["private_auction"] } serving_mode: ssai correlation_id: "ep-q4-2026-commitment/weather-app-ctv" + +# Inspecting this line item's VAST creative is subject to § 5.4.1. diff --git a/examples/line-item-display.yaml b/examples/line-item-display.yaml index 808af24..7798e0a 100644 --- a/examples/line-item-display.yaml +++ b/examples/line-item-display.yaml @@ -1,6 +1,6 @@ # OpenProposal 3.0 — Example: Display line item, fully worked (spec § 6.2) -# Shows selectable audiences with per-option pricing, a catalog reference, -# a buyer commitment, and settable bounds. +# Shows selectable audiences with per-option pricing, a buyer commitment, and +# settable bounds. line_item_id: "business-daily-display" status: published channel: display @@ -9,7 +9,7 @@ properties: available: [{ id: "business-daily", domains: ["business-daily.example"] }, { id: "markets-weekly", domains: ["markets-weekly.example"] }] included: ["business-daily"] - pricing: { markets-weekly: { delta_cpm: 2.00 } } + deltas: { markets-weekly: { delta_cpm: 2.00 } } max_select: 2 environments: @@ -18,20 +18,20 @@ environments: audiences: available: - - catalog_ref: "examplepub/audiences/smb-decision-makers@3" + - { id: "ep:smb-decision-makers", provenance: first_party, addressable_scale: 2600000 } - { id: "ep:hnw-investors", provenance: modeled, addressable_scale: 1100000 } - { id: "ep:cfo-finance-leaders", provenance: first_party, addressable_scale: 480000 } included: ["ep:smb-decision-makers"] - pricing: { "ep:hnw-investors": { delta_cpm: 4.00 }, + deltas: { "ep:hnw-investors": { delta_cpm: 4.00 }, "ep:cfo-finance-leaders": { delta_cpm: 6.50 } } max_select: 2 -addressable_scale: 3200000 # derived; recomputed on selection +addressable_scale: 3200000 # asserted; recomputed on selection activation_route: available: ["publisher_side", "clean_room"] included: ["publisher_side"] - pricing: { clean_room: { delta_cpm: 1.50 } } + deltas: { clean_room: { delta_cpm: 1.50 } } buyer_supplied: segments_allowed: true @@ -42,13 +42,13 @@ buyer_supplied: formats: available: ["display/300x250", "display/728x90", "display/970x250"] included: ["display/300x250"] - pricing: { "display/970x250": { delta_cpm: 3.00 } } + deltas: { "display/970x250": { delta_cpm: 3.00 } } creative_delivery: { method: buyer_hosted, protocol: javascript_tag, secure_required: true } -materials_due: "2026-09-24" +materials_due: { at: "2026-09-24T23:59:59Z" } geo: { allowed: ["US-NY", "US-CA", "US-IL", "US-TX"] } -frequency_cap: { max: { min: 1, max: 5 }, period: "day", basis: device } +frequency_cap: { count: { min: 1, max: 5 }, period: day, per: device } pricing: - cost_method: CPM @@ -65,8 +65,7 @@ commitment: basis: spend amount: 80000.00 currency: USD - flight_start: "2026-10-06" - flight_end: "2026-12-21" + flight: { start: "2026-10-06T00:00:00Z", end: "2026-12-21T23:59:59Z" } derived_units: 3333333 # derived: 80000.00 / 24.00 × 1000 idempotency_key: "agency-a-camp1-business-daily-display-8f42c1a9" @@ -76,23 +75,23 @@ hold_status: { hold_available: true, hold_duration: "P7D", committed_metrics: - { metric: viewable_rate, value: 0.70, basis: mrc } measurement_source: - available: [{ vendor: "IAS", attribution_window: "30d" }, - { vendor: "DoubleVerify", attribution_window: "30d" }] + available: [{ vendor: "IAS", attribution_window: "P30D" }, + { vendor: "DoubleVerify", attribution_window: "P30D" }] included: ["IAS"] shortfall_remedy: { available: ["makegood", "credit"], included: ["makegood"] } reporting: - available: [{ feed_type: aggregate, cadence: daily, delivery_mechanism: api }, - { feed_type: log_level, cadence: daily, delivery_mechanism: s3 }] + available: [{ feed_type: aggregate, cadence: day, delivery_mechanism: api }, + { feed_type: log_level, cadence: day, delivery_mechanism: s3 }] included: ["aggregate"] - pricing: { log_level: { delta_flat: 2500.00 } } + deltas: { log_level: { delta_flat: 2500.00 } } visibility: registry_gated required_credentials: [{ type: dsp_seat, scheme: jwt_keypair, issuer: "dsp-a", id_required: true }] buying_route: PUBLISHER_AD_SERVER -transaction_mechanism: { available: ["PROGRAMMATIC_GUARANTEED", "PREFERRED_DEAL"], - included: ["PROGRAMMATIC_GUARANTEED"] } +transaction_mechanism: { available: ["programmatic_guaranteed", "preferred_deal"], + included: ["programmatic_guaranteed"] } serving_mode: client_side buyer_requirements: { seat_required: true, dsp_allowed: ["dsp-a", "dsp-b"], dsp_seat_id: "seat-99812", dsp_id: "dsp-a-prod", diff --git a/examples/manifest.json b/examples/manifest.json new file mode 100644 index 0000000..7e45029 --- /dev/null +++ b/examples/manifest.json @@ -0,0 +1,103 @@ +{ + "seller_id": "reg:examplepub-digital", + "generated_at": "2026-10-03T15:00:00Z", + "spec_version": "3.0", + "proposals": [ + { + "proposal_id": "examplepub-us-q4-2026-omni", + "version": 1, + "seller_name": "Example Publisher US", + "description": "Cross-channel Example Publisher US package spanning display, in-stream video and CTV. Business and financial audience skew, weekday-heavy delivery.\n", + "offer_window": { + "start": "2026-10-01T00:00:00Z", + "end": "2026-12-31T23:59:59Z" + }, + "linked_execution": "none", + "full_url": "https://examplepub.example/openproposal/examplepub-us-q4-2026-omni", + "line_items": [ + { + "line_item_id": "streaming-audio", + "channel": "audio", + "environments": { + "available": [ + "web_mobile", + "app_mobile" + ] + }, + "formats": { + "included": [ + "audio/produced-30s" + ] + }, + "pricing": [ + { + "cost_method": "CPM", + "gross_rate": 18.0, + "currency": "USD" + } + ], + "commitment_bounds": { + "min_budget": 5000.0 + }, + "committed_metrics": [ + { + "metric": "completion_rate", + "value": 0.92, + "basis": "seller_reported" + } + ], + "transaction_mechanism": { + "available": [ + "preferred_deal" + ] + }, + "has_requires": false + }, + { + "line_item_id": "weather-app-ctv", + "channel": "ctv", + "environments": { + "available": [ + "ctv" + ] + }, + "properties": { + "available": [ + { + "id": "weather-app" + } + ] + }, + "formats": { + "included": [ + "ctv/instream-15s" + ] + }, + "pricing": [ + { + "cost_method": "CPM", + "gross_rate": 45.0, + "currency": "USD" + } + ], + "commitment_bounds": { + "min_budget": 25000.0 + }, + "committed_metrics": [ + { + "metric": "completion_rate", + "value": 0.95, + "basis": "seller_reported" + } + ], + "transaction_mechanism": { + "available": [ + "private_auction" + ] + }, + "has_requires": false + } + ] + } + ] +} diff --git a/examples/proposal.yaml b/examples/proposal.yaml index 9b51484..5b48c32 100644 --- a/examples/proposal.yaml +++ b/examples/proposal.yaml @@ -5,9 +5,11 @@ version: 1 seller_id: "reg:examplepub-digital" seller_name: "Example Publisher US" type: standard +linked_execution: none # line items may be bought separately (§ 4.1) status: published -valid_from: "2026-10-01T00:00:00Z" -valid_until: "2026-12-31T23:59:59Z" +offer_window: { start: "2026-10-01T00:00:00Z", end: "2026-12-31T23:59:59Z" } + +# The line_items of this proposal are shown in their own files (examples/line-item-*.yaml). description: > Cross-channel Example Publisher US package spanning display, in-stream video diff --git a/schema/README.md b/schema/README.md index 08026fe..a19f0c7 100644 --- a/schema/README.md +++ b/schema/README.md @@ -1,7 +1,7 @@ # Machine-readable schema **Coming.** Schemas are not published with this first draft; they will follow -the first revision after the comment period closes on 2026-10-22. +the first revision after the comment period closes. The draft in [`../spec/openproposal-3.0.md`](../spec/openproposal-3.0.md) is prose and tables. A JSON Schema is deliberately not published alongside this @@ -10,10 +10,8 @@ period exists to make — in particular: - whether per-field mutability belongs in the object at all (OQ-1), which determines whether a `selectable` field is an object with `available[]` / - `included[]` / `pricing` or simply a value; -- whether `catalog_ref` stays (OQ-2), which determines whether every field is a - `oneOf` between an inline value and a reference; -- whether the `environments[]` enum survives contact with real inventory (OQ-7). + `included[]` / `deltas` or a bare value; +- whether the `environments.available[]` list survives contact with real inventory (OQ-7). A schema will follow the first revision. Until then, the parseable examples in [`../examples/`](../examples/) are the best available reference for shape, and diff --git a/scripts/check_appendix.py b/scripts/check_appendix.py index fdaddf1..400f9bb 100755 --- a/scripts/check_appendix.py +++ b/scripts/check_appendix.py @@ -1,13 +1,28 @@ #!/usr/bin/env python3 -"""Guard against Appendix A drifting from the normative body. +"""Guard the field tables and Appendix A against drift. The spec says the body is normative and Appendix A is generated from it. This -checks the two agree on which fields exist and what marker each carries, so a -change request that updates one and forgets the other fails CI rather than -shipping. +checks that the two agree on which fields exist and on each field's type, +presence, and marker. It also enforces the table grammar of section 2.6: -Body tables are | `field` | Type | Marker | Description | -Appendix A is | `field` | Section | Type | Marker | + - Every table uses a layout in LAYOUTS. A new layout is an error until it is + added there on purpose. + - A field or key row names one key, in one code span, and a key ends [] exactly + when its Type is an Array. + - A Required cell is REQUIRED, OPTIONAL, RECOMMENDED, or REQUIRED when <...>. + - A marker is one that section 3 defines. + - A Type cell parses, a generic in it takes no bare Enum, and each name in it is a + base type, a shared type, or a named key table. Names and Object rows waiting on another change are in + PENDING_TYPES and PENDING_OBJECTS. Both lists only shrink. + - A key table follows a bold lead-in that names its type. + - The key table of a mixed field, and only that table, has a Mutability column of + markers. pricing[] has no key table: its rows carry the markers. + A lead-in's "each ``" names a marker of section 3 too. + - Every heading above the appendices is numbered. + - Normative text cites no issue number. + +Body tables are | `field` | Type | Required | Marker | Description | +Appendix A is | `field` | Section | Type | Required | Marker | Run: python3 scripts/check_appendix.py """ @@ -16,32 +31,237 @@ from pathlib import Path SPEC = Path(__file__).resolve().parent.parent / "spec" / "openproposal-3.0.md" -MARKERS = { - "immutable", "seller-set", "selectable", "settable", - "supplied", "requestable", "derived", "mixed", + +FIELD_LAYOUT = ("Field", "Type", "Required", "Mutability", "Description") +KEY_LAYOUT = ("Key", "Type", "Required", "Meaning") +KEY_MUTABILITY_LAYOUT = ("Key", "Type", "Required", "Meaning", "Mutability") + +LAYOUTS = { + FIELD_LAYOUT, + KEY_LAYOUT, + KEY_MUTABILITY_LAYOUT, + ("Type", "Form", "Rules"), + ("Marker", "Meaning"), + # Value tables (section 2.6): the first header cell names the key that holds the list. + ("`channel`", "Condition"), + ("`environments`", "Distribution channel", "Device types"), + # Other tables state rules as prose does (section 2.6). + ("OpenProposal concept", "Downstream standard"), + # Generated. + ("Field", "Section", "Type", "Required", "Marker"), + ("Marker", "Count", "Meaning", "Fields"), +} + +BASE_TYPES = { + "String", "Integer", "Number", "Boolean", "Enum", "Object", + "Array", "Map", "Range", "Selectable", "Settable", "T", +} + +# Named in a Type cell before the change that gives them a key table. Remove a +# name when its table lands. This list only shrinks. +PENDING_TYPES = { + "HistoryEntry": "the negotiation history entry", + "PropertyOption": "the property option", + "AudienceOption": "the audience option", + "BuyerSupplied": "the buyer-supplied audience data", + "Delta": "the price delta of an option", + "CancellationOption": "the cancellation option", + "ExclusivityTerm": "the exclusivity term", + "MinCommitment": "the purchase-level threshold", + "CommittedMetric": "the committed metric tuple", + "BuyerRequirements": "the buyer's seat and DSP requirements", + "ExternalReference": "the external reference entry", +} + +# Objects typed Object: open by definition, small and inline, or waiting on the +# change that names them. Remove an entry when its table lands. Only shrinks. +PENDING_OBJECTS = { + "`assent`": "waiting: the assent entry", + "`content_detail`": "waiting: the content the line item serves within", + "`specs`": "waiting: the format spec", + "`third_party_tags`": "waiting: the accepted tag protocols", + "`approval`": "waiting: the review policy", + "`daypart`": "waiting: the clock window", + "`availability`": "waiting: the availability figure", + "`commitment_bounds`": "waiting: the commitment bounds", + "`creative_policy`": "waiting: the creative policy", + "`required_credentials[]`": "waiting: the required credential", + "`billing`": "waiting: the billing terms", } -# Sections 4.x and 5.x carry the normative field tables. -BODY_SECTION = re.compile(r"^#{3,4} (4\.\d|5\.\d+(?:\.\d+)?)\s") + +BODY_SECTION = re.compile(r"^#{3,4} ((?:4|5)\.\d+(?:\.\d+)?)\s") +SECTION_3 = re.compile(r"^## 3\.") APPENDIX_A = re.compile(r"^## Appendix A") APPENDIX_B = re.compile(r"^## Appendix B") -BODY_ROW = re.compile(r"^\|\s*`([^`]+)`\s*\|\s*([^|]+?)\s*\|\s*([a-z-]+)\s*\|") -APPENDIX_ROW = re.compile( - r"^\|\s*`([^`]+)`\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([a-z-]+)\s*\|" -) +NUMBERED = re.compile(r"^#{2,4} (\d+\.(\d+(\.\d+)?)? )") +PRESENCE = re.compile(r"^(REQUIRED|OPTIONAL|RECOMMENDED|REQUIRED when .+)$") +ONE_KEY = re.compile(r"^`[^`]+`$") +LEAD_IN = re.compile(r"^\*\*`([A-Z][A-Za-z]+)`") +SHARED_TYPE = re.compile(r"`([A-Z][A-Za-z]+)`") +ISSUE_CITE = re.compile(r"(?= 0 and not lines[i].strip(): + i -= 1 + while i >= 0 and lines[i].strip() and not lines[i].startswith("|"): + m = LEAD_IN.match(lines[i]) + if m: + return m.group(1) + i -= 1 + return None + + +def lint_tables(lines, markers, errors): + declared = declared_types(lines) + objects_seen = set() + mixed_types, key_tables = set(), {} + for n, line in enumerate(lines): + for marker in re.findall(r"^\*\*`[A-Za-z]+`\*\* holds the keys of `[^`]+`, each `([^`]+)`", line): + if marker not in markers: + errors.append(f"line {n + 1}: marker not defined in section 3: {marker}") + i = 0 + while i < len(lines): + line = lines[i] + c = cells(line) + is_header = ( + c and i + 1 < len(lines) + and re.match(r"^\|\s*:?-{3}", lines[i + 1]) + ) + if not is_header: + i += 1 + continue + header = tuple(c) + if header not in LAYOUTS: + errors.append(f"line {i + 1}: table layout not in LAYOUTS: {line}") + if header in (KEY_LAYOUT, KEY_MUTABILITY_LAYOUT): + name = lead_in(lines, i) + if not name: + errors.append(f"line {i + 1}: a key table follows a lead-in that names its type") + key_tables[name] = (i + 1, header) + i += 2 + while i < len(lines) and lines[i].startswith("|"): + row = cells(lines[i]) + where = f"line {i + 1}" + if len(row) != len(header): + errors.append(f"{where}: {len(row)} cells under a {len(header)}-cell header") + elif header in (FIELD_LAYOUT, KEY_LAYOUT, KEY_MUTABILITY_LAYOUT): + key, typ, required = row[0], row[1], row[2] + if not ONE_KEY.match(key): + errors.append(f"{where}: a row names one key: {key}") + if key.endswith("[]`") != typ.startswith("Array["): + errors.append(f"{where}: a key ends [] exactly when it holds an Array: {key} {typ}") + if not PRESENCE.match(required): + errors.append(f"{where}: Required cell is not in the grammar: {required}") + names = type_names(typ) + if names is None: + errors.append(f"{where}: Type cell does not parse: {typ}") + else: + for name in names: + if name not in BASE_TYPES | declared | set(PENDING_TYPES): + errors.append(f"{where}: Type names an undefined type: {name}") + if "Enum" in names and names != ["Enum"]: + errors.append(f"{where}: a generic never takes Enum: {typ}") + if "Object" in names: + objects_seen.add(key) + if key not in PENDING_OBJECTS: + errors.append(f"{where}: {key} is an Object with no key table") + if header == FIELD_LAYOUT and row[3] not in markers: + errors.append(f"{where}: marker not defined in section 3: {row[3]}") + if header == FIELD_LAYOUT and row[3] == "mixed": + mixed_types.update(n for n in re.findall(r"[A-Za-z]+", typ) if n != "Array") + if header == KEY_MUTABILITY_LAYOUT and row[4] not in markers: + errors.append(f"{where}: marker not defined in section 3: {row[4]}") + i += 1 + for name, (line, header) in key_tables.items(): + if (name in mixed_types) != (header == KEY_MUTABILITY_LAYOUT): + errors.append(f"line {line}: {name}: the key table of a mixed field, and only it, has a Mutability column") + for name, owner in PENDING_TYPES.items(): + if name in declared: + errors.append(f"{name} has a key table now: remove it from PENDING_TYPES ({owner})") + for key, owner in PENDING_OBJECTS.items(): + if key not in objects_seen: + errors.append(f"{key} is no longer an Object row: remove it from PENDING_OBJECTS ({owner})") + + +def lint_headings(lines, errors): + for i, line in enumerate(lines): + if APPENDIX_A.match(line): + break + if line.startswith("##") and not NUMBERED.match(line): + errors.append(f"line {i + 1}: heading has no number: {line}") + + +def lint_citations(lines, errors): + active = True + for i, line in enumerate(lines): + if line.startswith("## 8."): + active = False # Section 8 tracks open questions, which are issues. + if APPENDIX_A.match(line): + active = True + if active and ISSUE_CITE.search(line): + errors.append(f"line {i + 1}: normative text cites an issue: {line.strip()[:80]}") + + +def marker_table(lines): + start = next(i for i, l in enumerate(lines) if SECTION_3.match(l)) + markers = [] + for line in lines[start:]: + if line.startswith("## ") and not SECTION_3.match(line): + break + c = cells(line) + if c and len(c) == 2 and c[0].startswith("`"): + markers.append(c[0].strip("`")) + return markers def collect(rows): - """Keep the first marker seen per field, and note fields declared twice. + """Keep the first row seen per field, and flag a repeat that differs. - `status` legitimately appears twice — once on the proposal (4.1) and once on - the line item (5.1) — so a repeat is not an error, but the markers must agree. + `status` legitimately appears on the proposal (4.1) and on the line item + (5.1), so a repeat is not an error, but the type and marker must agree + between the body and the appendix for each pair. """ seen = {} - for field, marker in rows: - if field in seen and seen[field] != marker: - seen[field] = f"CONFLICT({seen[field]}/{marker})" - else: - seen.setdefault(field, marker) + for field, *rest in rows: + seen.setdefault(field, []).append(tuple(rest)) return seen @@ -53,59 +273,70 @@ def main(): print("Could not find '## Appendix A' in the spec.") return 1 - body_rows, active = [], False + markers = marker_table(lines) + body_rows, active, in_field = [], False, False for line in lines[:a_start]: if line.startswith("#"): active = bool(BODY_SECTION.match(line)) + in_field = False + continue if not active: continue - m = BODY_ROW.match(line) - if m and m.group(3) in MARKERS: - body_rows.append((m.group(1), m.group(3))) + c = cells(line) + if not c: + in_field = False + elif tuple(c) == FIELD_LAYOUT: + in_field = True + elif in_field and not c[0].startswith(":") and len(c) == 5: + body_rows.append((c[0], c[1], c[2], c[3])) appendix_rows = [] for line in lines[a_start:]: if APPENDIX_B.match(line): break - m = APPENDIX_ROW.match(line) - if m and m.group(4) in MARKERS: - appendix_rows.append((m.group(1), m.group(4))) + c = cells(line) + if c and len(c) == 5 and c[0].startswith("`"): + appendix_rows.append((c[0], c[2], c[3], c[4])) body = collect(body_rows) appendix = collect(appendix_rows) errors = [] if not body: - errors.append("parsed no fields from the body — the table format changed") + errors.append("parsed no fields from the body: the table format changed") if not appendix: - errors.append("parsed no fields from Appendix A — the table format changed") + errors.append("parsed no fields from Appendix A: the table format changed") - for field, marker in sorted(body.items()): + for field, values in sorted(body.items()): if field not in appendix: - errors.append(f"in the body but missing from Appendix A: `{field}`") - elif appendix[field] != marker: + errors.append(f"in the body but missing from Appendix A: {field}") + elif appendix[field] != values: errors.append( - f"marker disagrees for `{field}`: " - f"body says {marker}, Appendix A says {appendix[field]}" + f"type, presence, or marker disagrees for {field}: " + f"body says {values}, Appendix A says {appendix[field]}" ) for field in sorted(appendix): if field not in body: - errors.append(f"in Appendix A but not in a body field table: `{field}`") + errors.append(f"in Appendix A but not in a body field table: {field}") + + lint_tables(lines, markers, errors) + lint_headings(lines, errors) + lint_citations(lines, errors) if errors: - print("Appendix A has drifted from the normative body:\n") + print("The tables have drifted from the grammar or from Appendix A:\n") for e in errors: print(f" - {e}") print( - "\nThe body is normative. Update Appendix A (and the counts in " - "Appendix B) to match it." + "\nThe body is normative. Run regen_appendices.py after a body change, " + "and fix what the lint names." ) return 1 - # Fewer unique keys than Appendix A rows is expected: the combined - # `valid_from` · `valid_until` row is skipped, and `status` appears on both - # the proposal and the line item, collapsing to one key. - print(f"Appendix A matches the body: {len(body)} fields, markers agree.") + print( + f"Appendix A matches the body: {len(body)} field names, " + f"{len(body_rows)} rows. Table grammar holds." + ) return 0 diff --git a/scripts/check_examples.py b/scripts/check_examples.py new file mode 100755 index 0000000..d31b0fb --- /dev/null +++ b/scripts/check_examples.py @@ -0,0 +1,552 @@ +#!/usr/bin/env python3 +"""Check every example against the tables of the specification. + +Every rule here reads the spec, so a table change reaches the examples without a +change to this script. It checks: + + - Each file parses strictly: no duplicate keys, no implicit dates. + - Each REQUIRED field and key is present. A `REQUIRED when ` cell is + tested by its entry in CONDITIONS, and a wording with no entry is an error. + - Each key at a typed position is a key its table lists, and each value has its + Type: Instant, Duration, Digest, Domain, VocabularyTerm, the other shared types, + and the base types. An Enum value is on the list its row opens with. + - An option name in included[] or deltas names an option in available[]. + - The manifest is a strict path subset of the full objects (section 2.1), and lists + each public line item (section 2.3). + +KNOWN holds findings that a later change removes. It only shrinks. Run with +--regen-manifest to rewrite examples/manifest.json from the full examples. + +Run: python3 scripts/check_examples.py +""" +import argparse +import json +import re +import sys +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +SPEC = ROOT / "spec" / "openproposal-3.0.md" +EXAMPLES = ROOT / "examples" + +# Object types whose extra keys are allowed. +OPEN_TYPES = {"DocumentRef"} +# A named type that holds the keys of another. +COMPOSES = {"Commitment": ["CommitmentAmount"], "ManifestEntry": ["ProposalSummary"]} + +# (file, path, finding): removed by the change named in the comment. +KNOWN = { + # Option ids: these options are named by a key value until option ids are defined. + ("line-item-display.yaml", "measurement_source.included", "IAS"), + ("line-item-display.yaml", "reporting.included", "aggregate"), + ("line-item-display.yaml", "reporting.deltas", "log_level"), + # Line items: proposal.yaml shows them in their own files (section 6.1), until the + # change that lists the published line items inline in proposal.yaml. + ("proposal.yaml", "(root)", "line_items"), +} + + +# Each `REQUIRED when ` wording, as (object, line item, proposal) -> bool. +CONDITIONS = { + "`type` is `custom`": lambda o, i, p: o.get("type") == "custom", + "`basis` is `spend`": lambda o, i, p: o.get("basis") == "spend", + "`at` is absent": lambda o, i, p: "at" not in o, + "`before_start` is absent": lambda o, i, p: "before_start" not in o, + "`url` is absent": lambda o, i, p: "url" not in o, + "`markup` is absent": lambda o, i, p: "markup" not in o, +} + +BASE = {"String", "Integer", "Number", "Boolean", "Enum", "Object"} +INSTANT = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z") +# The RFC 3339 Appendix A grammar without its week form: section 2.2 writes P14D, never P2W. +_DUR_TIME = r"T(?:\d+H(?:\d+M(?:\d+S)?)?|\d+M(?:\d+S)?|\d+S)" +_DUR_DATE = r"(?:\d+Y(?:\d+M(?:\d+D)?)?|\d+M(?:\d+D)?|\d+D)" +DURATION = re.compile(rf"P(?:{_DUR_DATE}(?:{_DUR_TIME})?|{_DUR_TIME})\Z") +DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") +DOMAIN = re.compile(r"[a-z0-9-]+(\.[a-z0-9-]+)+\Z") +# The VocabularyTerm form of section 2.2: a bare term, or a domain and a term. +TERM_PART = r"[A-Za-z0-9_-]+" +TERM_DOMAIN = r"[a-z0-9-]+(?:\.[a-z0-9-]+)+:" +TERM = re.compile(rf"(?:{TERM_DOMAIN})?{TERM_PART}\Z") +SIDE = ("buyer", "seller") +CALENDAR_UNIT = ("hour", "day", "week", "month") +SCALARS = { + "Instant": (lambda v: isinstance(v, str) and INSTANT.match(v)), + "Duration": (lambda v: isinstance(v, str) and DURATION.match(v)), + "Digest": (lambda v: isinstance(v, str) and DIGEST.match(v)), + "Domain": (lambda v: isinstance(v, str) and DOMAIN.match(v)), + "RegistrableDomain": (lambda v: isinstance(v, str) and DOMAIN.match(v)), + "Measurer": (lambda v: v in SIDE or (isinstance(v, str) and DOMAIN.match(v))), + "VocabularyTerm": (lambda v: isinstance(v, str) and TERM.match(v)), + "MediaChannel": (lambda v: isinstance(v, str) and TERM.match(v)), + "String": (lambda v: isinstance(v, str)), + "Url": (lambda v: isinstance(v, str) and v.startswith(("http://", "https://"))), + "Integer": (lambda v: isinstance(v, int) and not isinstance(v, bool)), + "IntegerFlag": (lambda v: v in (0, 1) and not isinstance(v, bool)), + "Number": (lambda v: isinstance(v, (int, float)) and not isinstance(v, bool)), + "Amount": (lambda v: isinstance(v, (int, float)) and not isinstance(v, bool)), + "Boolean": (lambda v: isinstance(v, bool)), + "Fraction": (lambda v: isinstance(v, (int, float)) and not isinstance(v, bool) and 0 <= v <= 1), + "FractionDelta": (lambda v: isinstance(v, (int, float)) and not isinstance(v, bool) and v > -1), + "CalendarUnit": (lambda v: v in CALENDAR_UNIT), + "TimeUnit": (lambda v: v in CALENDAR_UNIT + ("flight",)), + "ZoneName": (lambda v: isinstance(v, str)), + "IanaZone": (lambda v: isinstance(v, str)), + "Side": (lambda v: v in SIDE), + "EntryRef": (lambda v: isinstance(v, str) and re.fullmatch(r"[^/@]+/[^/@]+/[^/@]+@[^/@]+", v)), + "FieldPointer": (lambda v: isinstance(v, str) and (v == "" or v.startswith("/"))), +} + + +class StrictLoader(yaml.SafeLoader): + """Reject duplicate keys, and keep dates as strings to reject them by type.""" + + +def _strict_map(loader, node): + out = {} + for k_node, v_node in node.value: + key = loader.construct_object(k_node, deep=True) + if key in out: + raise yaml.YAMLError(f"duplicate key {key!r} at line {k_node.start_mark.line + 1}") + out[key] = loader.construct_object(v_node, deep=True) + return out + + +StrictLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _strict_map) +StrictLoader.yaml_implicit_resolvers = { + k: [(tag, rx) for tag, rx in v if tag != "tag:yaml.org,2002:timestamp"] + for k, v in yaml.SafeLoader.yaml_implicit_resolvers.items() +} + + +def load_yaml(path): + return yaml.load(path.read_text(), Loader=StrictLoader) + + +def load_json(path): + def pairs(items): + keys = [k for k, _ in items] + if len(keys) != len(set(keys)): + raise ValueError(f"duplicate key in {path.name}") + return dict(items) + + return json.loads(path.read_text(), object_pairs_hook=pairs) + + +# Spec reading -------------------------------------------------------------- + + +def cells(line): + body = line.strip() + if not (body.startswith("|") and body.endswith("|")): + return None + return [p.strip() for p in re.split(r"(?//@" -example: "examplepub/audiences/smb-decision-makers@3" -``` +One concept has one type, and every field that holds the concept uses it. A field +that holds a shared type states what the value means there, not its form. Its row +MAY narrow the type for that field, by requiring a key the type leaves OPTIONAL or by +limiting the values or count the type allows, and says so. A row never loosens a +shared type or changes its form. + +| Type | Form | Rules | +| :--- | :--- | :--- | +| `Digest` | String of 71 characters: `sha256:` followed by the 64 lowercase hexadecimal characters of the SHA-256 of its input | The value carries the algorithm, and a field name never names it. `sha256` is the only algorithm in this version. Each field that holds a `Digest` states its input | +| `Domain` | String: lowercase ASCII, internationalized labels in A-label form ([RFC 5890](https://www.rfc-editor.org/rfc/rfc5890)), with no scheme, port, path, or trailing dot | Producers MUST write this form. Consumers MUST compare byte for byte and MUST NOT normalize a value before comparing it. A value read from ads.txt or sellers.json is converted to this form before comparison | +| `RegistrableDomain` | A `Domain` that is the [Public Suffix List](https://publicsuffix.org/) registrable domain, taken over the whole list, PRIVATE section included | It names a business | +| `Side` | `buyer` \| `seller` | It says which side, never who that side is. `Party` says who | +| `Measurer` | A `Side`, or a `RegistrableDomain` | Who counts or measures. A seller writes `seller`, never its own domain. Two values name one party only when they are equal byte for byte. A `qualifier` beside a value names a product or method of that party and is not compared | +| `EntryRef` | String: `//@`, with no `/` or `@` inside a part | A named entry at one exact version, whose content does not change. `` is a `Domain`. It carries no digest | +| `VocabularyTerm` | String: a bare core term, or `:` with `` a `Domain` the definer controls. A term is ASCII letters, digits, `_`, and `-` | See below | +| `CalendarUnit` | `hour` \| `day` \| `week` \| `month` | A calendar bucket | +| `TimeUnit` | A `CalendarUnit`, or `flight` | `flight` is the whole of `commitment.flight` | +| `IntegerFlag` | Integer: `0` (no) \| `1` (yes) | A yes or no that another standard writes as an integer. Each field says what `1` affirms | +| `Amount` | Number | Money. It carries no currency: the field that holds it states which one it reads in | +| `Fraction` | Number from 0 to 1 | A proportion | +| `FractionDelta` | Number greater than -1 | Used only by a price delta expressed as a proportion | +| `Instant` | String: an RFC 3339 `date-time` in whole seconds, ending in `Z` | A point in time. One written form, so one instant has one digest input | +| `Duration` | String: the ISO 8601 grammar of RFC 3339 Appendix A without its week form (`P14D`, not `P2W`) | A length of time. A comparison names its anchor. A length within the playback of an ad or a placement, such as a play, view, read, loop, slot, or refresh, is instead an Integer whose key ends `_s` or `_ms`, except a key imported from another standard, which keeps its spelling (§ 2.6) | +| `IanaZone` | String: a name in the [IANA time zone database](https://www.iana.org/time-zones), such as `America/New_York` or `UTC` | A time zone | +| `ZoneName` | An `IanaZone`, or `viewer_local` | `viewer_local` is where the ad renders | +| `Url` | String: an absolute `http` or `https` URL ([RFC 3986](https://www.rfc-editor.org/rfc/rfc3986)) | A location a client can fetch | +| `FieldPointer` | String: a JSON Pointer ([RFC 6901](https://www.rfc-editor.org/rfc/rfc6901)) | A location in the object this specification defines | + +**`VocabularyTerm` rules.** A field that takes a `VocabularyTerm` lists its core +terms. Anyone MAY define an extension term. Terms compare byte for byte. A +published meaning never changes; a changed meaning takes a new term. IAB Tech Lab +keeps core lists apart from the schema, so a new core term needs no schema change. +A receiver that doesn't know a term treats the option or entry that holds it as +unavailable. A term in an `Array[VocabularyTerm]` is not an entry of its own: it +belongs to the option or entry that holds the array. Where the term binds outside +any option or entry, the receiver skips the line item and doesn't reject the +proposal because of it. For a `channel` (§ 5.1.1) or `cost_method` (§ 5.6) term, +these effects are a MUST. A receiver never assents to what it can't read. A field MAY +state another effect. + +**`DocumentRef`** pins a binding document. The object is open, so its publisher can +add a version or a date. It is never inlined. The publisher keeps every version +retrievable, and the counterparty stores a copy. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `url` | Url | REQUIRED | The document. It MAY need authentication | +| `digest` | Digest | REQUIRED | Over the exact bytes served, with no content coding | + +**`VersionRef`** locates an offer at a version. It carries no digest. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `proposal_id` | String | REQUIRED | The proposal | +| `version` | Integer | REQUIRED | The proposal's `version` | +| `line_item_id` | String | OPTIONAL | A line item in that version | + +**`Party`** names a legal party. `domain` is the identifier, and `registry_id` is +an attestation. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `domain` | RegistrableDomain | REQUIRED | The party's domain | +| `legal_name` | String | OPTIONAL | The party's legal name | +| `registry_id` | String | OPTIONAL | An IAB Tech Lab Agent Registry reference for the entity controlling `domain` | + +**`SeatRef`** names a buyer's seats. A seat always travels with its DSP. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `dsp` | RegistrableDomain | REQUIRED | The DSP's primary domain | +| `ids[]` | Array[String] | OPTIONAL | The ids the DSP issues, the same values OpenRTB 2.6 carries in `Deal.wseat` | + +**`TaxonomyCodes`** holds codes in one taxonomy. Each field that holds it names the +list `taxonomy` comes from and whether an id covers its descendants. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `taxonomy` | Integer | REQUIRED | A value from the list the field names | +| `ids[]` | Array[String] | REQUIRED | Ids in that taxonomy | + +**`Interval`** is an absolute window, with `end` later than `start`. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `start` | Instant | REQUIRED | The first second | +| `end` | Instant | REQUIRED | The last second, inclusive | + +**`Range`** bounds a value of type `T`, written `Range[T]`. At least one bound is +present, and `min` is no greater than `max`. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `min` | T | OPTIONAL | The lower bound | +| `max` | T | OPTIONAL | The upper bound | + +### 2.3 Publication + +A seller MAY publish `https:///.well-known/openproposal.json`, a +`ProposalManifest`. -- A field MUST carry either an inline value or a `catalog_ref`, never both. -- A `catalog_ref` is **not inheritance**. There are no override semantics, and - nothing is implied by a field's position in the document. The no-inheritance - rule stands. -- References MUST be version-pinned. A catalog entry MUST be immutable once - referenced; a change creates a new version. -- References MUST resolve during discovery. -- When a proposal reaches status `agreed`, every `catalog_ref` MUST be replaced - by its resolved value in the stored record, so the committed artifact is - self-contained and immutable. +**`ProposalManifest`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `seller_id` | String | REQUIRED | The seller, as each listed proposal's `seller_id` (§ 4.1) | +| `generated_at` | Instant | REQUIRED | When the seller generated the file | +| `spec_version` | String | REQUIRED | The version of this specification the file follows | +| `proposals[]` | Array[ManifestEntry] | REQUIRED | One per listed proposal | + +**`ManifestEntry`** holds the keys of `ProposalSummary` (§ 2.1) and this one: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `full_url` | Url | REQUIRED | Where the second phase fetches the Full Representation | + +- `ETag` / `Last-Modified` are REQUIRED, so buyers can poll cheaply. +- Only standard proposals appear, and within them only line items whose + `visibility` (§ 5.8) is `public`. `registry_gated` and `named` line items do + not. +- Manifest entries MUST match the current version of each proposal. A stale + entry is a seller error. + +Two-phase flow: + +1. **Discover:** fetch the manifest and screen the summaries. No language model + is required. +2. **Propose:** fetch the Full Representation of shortlisted line items from + `full_url`, then compose and commit as usual. + +### 2.6 Table and naming conventions + +**Tables.** A field table has the columns Field, Type, Required, Mutability, and +Description. A key table has the columns Key, Type, Required, and Meaning, and MAY +add a last column that names a source standard. The key table of a `mixed` field adds +Mutability as its last column. A row names one key, and a key that holds an array ends +`[]`. + +A value table has one row per value of a list. Its first header cell is the code name +of the key that holds the list. Its first column equals the list, so it defines no +second one, unless the text names the table as the list. The § 2.2 table has one row +per shared type. A Form that adds a value to a type reads ``A `T`, or `b` ``, and one +that adds a second type reads ``A `T`, or a `U` ``. The § 3 table +has one row per marker. Any other table states rules as prose does, and defines no +key, type, or list. + +**Presence.** A Required cell is `REQUIRED`, `OPTIONAL`, `RECOMMENDED`, or `REQUIRED +when `. Presence is relative to the object that holds the key, and a row +that narrows a shared type (§ 2.2) may require a key of it. A key that must be absent +under a condition says so in its Meaning or Description. A lead-in may limit its +table's keys instead, with `holds exactly one key` or `At least one is present`. + +**Types.** A Type cell holds `String`, `Integer`, `Number`, `Boolean`, `Enum`, +`Object`, a shared type (§ 2.2), a type its section names, or a named key table. +`Array[T]`, `Map[K, V]`, `Range[T]`, `Selectable[T]` (§ 3.1), and `Settable[T]` +(§ 3.2) take other types. `T`, `K`, and `V` stand for a type's arguments, and a Type +cell inside that type's table may name them. `Enum` is a closed list of strings, +written in the row that uses it, and a generic never takes it: a list a generic takes +has a name. A closed list of integers written in a row has Type `Integer`. `Object` +holds keys that another definition sets. A lead-in that reads "`X` holds the keys of +`Y`" gives `X` every key of `Y`, with the rules `Y` states on them, and the rows of its +own table. The keys sit flat in `X`, and `X` doesn't repeat them. `Y` may take a type, +such as `Range[T]`. A lead-in that adds "each ``" gives the composed keys that +marker. A lead-in that reads "`X` lists these paths" is followed by a `text` block with +one path per line. The first segment of a path is a key of `X`, and later segments nest +as they do in the object `X` is a subset of. A segment written `` stands for what +the lead-in says. A key table led in by "`X` adds this key" or "`X` adds these keys" +follows that block and gives `X` those keys beside the listed paths. + +**Value lists.** A list is written once, as `` `a` \| `b` ``, or `` `a` `` alone when it +has one value, or as the first column of a value table the text names as the list. +Other mentions point to it. A `VocabularyTerm` field says so and lists its core terms, +or points to other lists, whole or in part, and adds any others; +every other list is closed, and in a row it opens the Meaning cell. + +**Keys.** Every key is snake_case, except in an object that a section copies from +another standard and says so; that object keeps the standard's spelling and cites +it. A key name repeats only where it means the same thing, and each row's Type cell +states its form there. An array named by a noun is plural, except the mass nouns +`pricing[]` and `negotiation_history[]`, and a name another standard spells. A name +that isn't a noun keeps its form, such as `available[]`, `best_for[]`, and `all_of[]`. +The suffix `_ref` is reserved for `DocumentRef` and `EntryRef`. A Boolean flag may end +`_allowed` or `_required`, and a list never does. + +**Values.** Every term compares byte for byte. A list this specification owns is +lower snake case. A list taken from another standard keeps that standard's spelling +and names it. A list built on another standard's list follows its case and names it: +`cost_method` is upper case, like OpenDirect 2.1 `Line.ratetype`. + +**Yes or no.** A key from another standard keeps that standard's values, as +`IntegerFlag` does. Every other flag is a Boolean. + +**Nothing.** `none` is a value only where it has its own meaning or the key is +required. Otherwise, absence means none. + +**Paths.** Prose names a field by its full table path, such as +`properties.available[].domains[]`. The key alone is enough in the section that +holds its table, beside the object it belongs to, or beside a reference to that +section. A value that points into a document is a `FieldPointer`. --- @@ -120,11 +345,15 @@ Every field carries exactly one marker. | :--- | :--- | | `immutable` | Set once at creation. Neither party may change it. | | `seller-set` | The seller sets it and MAY revise it while status is `draft` or `published`. The buyer cannot change it directly. | -| `selectable` | The buyer chooses from a seller-declared set. Each option MAY carry a price delta. Resolves without seller assent. | -| `settable` | The buyer supplies a value within seller-declared bounds. Resolves without seller assent. | +| `selectable` | The buyer chooses from a seller-declared set. Each option MAY carry a price delta. Resolves without a round trip. | +| `settable` | The buyer supplies a value within seller-declared bounds. Resolves without a round trip. | | `supplied` | The buyer provides data or an artifact. The seller does not pre-populate it. | | `requestable` | The buyer MAY ask for a value outside the declared space. Requires explicit seller assent. | -| `derived` | Computed from other fields. Neither party sets it directly. | +| `derived` | Computed by a rule this specification states. Neither party sets it directly. | +| `lifecycle` | Live state of the object. The party its section names moves it between the stated values. Not a term. | +| `recorded` | Written by the party that acted. Append-only: an entry is never edited or removed. Not a term. | +| `asserted` | The seller states it from data the buyer can't recompute. Not a term, and it changes without a new version. | +| `mixed` | The keys of the object carry different markers: in the Mutability column of its key table, in its lead-in for composed keys, or on its rows for `pricing[]`. | **Resolution without a round trip.** `selectable`, `settable` and `supplied` fields MUST resolve without a round trip — everything needed to validate and @@ -137,26 +366,26 @@ the proposal. ### 3.1 Selectable structure -Every field marked `selectable` MUST declare: +Every field marked `selectable` is a `Selectable[T]`, where `T` is the option type. -| Key | Required | Meaning | -| :--- | :--- | :--- | -| `available[]` | REQUIRED | The options the seller offers | -| `included[]` | REQUIRED | Which options are in the base rate. MAY be empty | -| `pricing` | OPTIONAL | Delta per option beyond those included, as `{ option_id: { delta_cpm \| delta_flat \| delta_pct } }` | -| `max_select` | OPTIONAL | Cap on how many options the buyer may choose | +**`Selectable`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `available[]` | Array[T] | REQUIRED | The options the seller offers | +| `included[]` | Array[String] | REQUIRED | Which options are in the base rate. MAY be empty | +| `deltas` | Map[String, Delta] | OPTIONAL | Delta per option beyond those included, keyed by option: one of `delta_cpm`, `delta_flat`, or `delta_pct` | +| `max_select` | Integer | OPTIONAL | Cap on how many options the buyer may choose | A field marked `selectable` that carries a bare value and no `available[]` MUST be treated as `seller-set`. -`audience_pricing` in earlier drafts was the precedent for this pattern — it -expressed "if you change X, the price moves by Y". `pricing` generalises it to -every dimension a seller is willing to vary, and replaces it. - ### 3.2 Settable bounds -Every field marked `settable` MUST declare its bounds: either `allowed[]` (an -enumerated set) or `min` / `max` (a numeric or date range). +Every field marked `settable` is a `Settable[T]` and MUST declare its bounds: +either `allowed[]` (an enumerated set) or `min` / `max` (a numeric or date range). +`frequency_cap` declares its bounds in `count`, and `commitment` within +`commitment_bounds` (§ 5.6). A field marked `settable` that carries a bare value and no bounds MUST be treated as `seller-set`. @@ -178,30 +407,32 @@ items. ### 4.1 Identity and lifecycle -| Field | Type | Mutability | Description | -| :--- | :--- | :--- | :--- | -| `proposal_id` | String | immutable | Unique identity for the proposal object | -| `version` | Integer | derived | Monotonically increasing revision number | -| `seller_id` | String | immutable | IAB Tech Lab registry reference | -| `seller_name` | String | immutable | Canonical legal seller entity name, from the registry | -| `type` | Enum | immutable | `standard` \| `custom` | -| `status` | Enum | derived | `draft` \| `published` \| `under_review` \| `agreed` \| `withdrawn` \| `expired` | -| `assent` | Object | derived | Buyer and seller assent records and timestamps for the binding version. Signature scheme is WS1 | -| `valid_from` · `valid_until` | Timestamp | seller-set | ISO-8601 window the offer holds. **This is the offer window, not the campaign flight** | -| `brief_ref` | String | supplied | Reference to the buyer's brief. REQUIRED when `type` is `custom` | -| `negotiation_history[]` | Array[Object] | derived | Ordered, append-only. `{ version, actor: buyer \| seller, action: proposed \| countered \| accepted \| declined \| withdrawn, fields_changed[], at }`. Entries MUST NOT be edited or removed | +| Field | Type | Required | Mutability | Description | +| :--- | :--- | :--- | :--- | :--- | +| `proposal_id` | String | REQUIRED | immutable | Unique identity for the proposal object | +| `version` | Integer | REQUIRED | derived | Monotonically increasing revision number | +| `seller_id` | String | REQUIRED | immutable | IAB Tech Lab registry reference | +| `seller_name` | String | OPTIONAL | immutable | Canonical legal seller entity name, from the registry | +| `type` | Enum | REQUIRED | immutable | `standard` \| `custom` | +| `status` | Enum | REQUIRED | derived | `draft` \| `published` \| `under_review` \| `agreed` \| `withdrawn` \| `expired` | +| `assent` | Object | OPTIONAL | derived | Buyer and seller assent records and timestamps for the binding version. Signature scheme is WS1 | +| `offer_window` | Interval | OPTIONAL | seller-set | The window the offer holds. **This is the offer window, not the campaign flight** | +| `brief_id` | String | REQUIRED when `type` is `custom` | immutable | The buyer's brief this proposal answers | +| `negotiation_history[]` | Array[HistoryEntry] | OPTIONAL | recorded | Ordered, append-only. `{ version, actor, action: proposed \| countered \| accepted \| declined \| withdrawn, paths[], at }`. `actor` is a `Side`, `at` an `Instant`, and each `paths[]` entry `{ line_item_id, pointer }`, with `line_item_id` OPTIONAL and `pointer` a `FieldPointer` into that line item, or into the proposal when `line_item_id` is absent. Entries MUST NOT be edited or removed | +| `line_items[]` | Array[LineItem] | REQUIRED | seller-set | The line items (§ 5), in the order the seller wrote them | +| `linked_execution` | Enum | OPTIONAL | seller-set | `none` (default, subsettable) \| `all` (every published line item must be committed together) | ### 4.2 Agent comprehension The case a buying agent reads to decide whether to look further. -| Field | Type | Mutability | Description | -| :--- | :--- | :--- | :--- | -| `description` | String | seller-set | REQUIRED. Prose, not a name. Where the "why buy" case is made, including which channels a multi-channel proposal spans | -| `specifications` | String/Object | seller-set | Structured detail at media-kit depth | -| `best_for[]` | Array[String] | seller-set | Advisory planning hints. Non-normative, and never the basis for a match | -| `not_suitable_for[]` | Array[String] | seller-set | Advisory exclusion guidance. Non-normative | -| `seasonality_notes` | String | seller-set | When the proposal performs differently | +| Field | Type | Required | Mutability | Description | +| :--- | :--- | :--- | :--- | :--- | +| `description` | String | REQUIRED | seller-set | Prose, not a name. Where the "why buy" case is made, including which channels a multi-channel proposal spans | +| `specifications` | String | OPTIONAL | seller-set | Detail at media-kit depth | +| `best_for[]` | Array[String] | OPTIONAL | seller-set | Advisory planning hints. Non-normative, and never the basis for a match | +| `not_suitable_for[]` | Array[String] | OPTIONAL | seller-set | Advisory exclusion guidance. Non-normative | +| `seasonality_notes` | String | OPTIONAL | seller-set | When the proposal performs differently | --- @@ -212,144 +443,347 @@ channel. ### 5.1 Identity -| Field | Type | Mutability | Description | -| :--- | :--- | :--- | :--- | -| `line_item_id` | String | immutable | Identity within the proposal | -| `status` | Enum | derived | `draft` \| `published` \| `under_review` \| `agreed` \| `withdrawn` \| `sold_out` \| `expired`. Independent of the proposal's status, so one line item may sell out while the rest of the shelf stays live | -| `channel` | Enum | immutable | `display` \| `video` \| `audio` \| `ctv` \| `dooh` \| `native`. The discriminator for how the rest of the line item is read | +| Field | Type | Required | Mutability | Description | +| :--- | :--- | :--- | :--- | :--- | +| `line_item_id` | String | REQUIRED | immutable | Identity within the proposal | +| `status` | Enum | REQUIRED | lifecycle | `draft` \| `published` \| `under_review` \| `agreed` \| `withdrawn` \| `sold_out` \| `expired`. Independent of the proposal's status, so one line item may sell out while the rest of the shelf stays live. The seller moves it | +| `channel` | MediaChannel | REQUIRED | immutable | Core terms in § 5.1.1. The discriminator for how the rest of the line item is read | + +#### 5.1.1 channel + +An impression's channel is the first row of this table that the impression +matches. Conditions are stated in +[AdCOM 1.0](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md) +terms: the +[distribution channel](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#abstract_distributionchannel), +the ad's media +([`Placement`](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#object_placement) +subtypes) and the +[device type](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#list_devicetypes). +Where an impression accepts more than one medium, its media is that of the ad +served. + +| `channel` | Condition | +| :--- | :--- | +| `dooh` | Distribution channel `Dooh` | +| `audio` | Audio ad (`AudioPlacement`) | +| `ctv` | Device type Connected TV (3), Connected Device (6) or Set Top Box (7) | +| `native` | Native ad (`DisplayPlacement.nativefmt`), including native ads with video assets | +| `video` | Video ad (`VideoPlacement`) | +| `display` | Any other display ad (`DisplayPlacement`) | + +**`MediaChannel`** is a `VocabularyTerm` (§ 2.2) whose core terms are the rows of this +table. An impression a row matches has that row's channel, so an extension term +covers only impressions no row matches. + +Device types are values of AdCOM 1.0 List: Device Types, each written as its +name followed by its code. + +- The seller MUST serve a line item only on impressions of its `channel`. + Placement type, such as interstitial or rewarded + ([OpenRTB 2.6](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/1debba8001f40eb14597102dec3205afddf811d4/2.6.md#objectimp) + `video.plcmt`, `imp.instl` and `imp.rwdd`), does not change the channel. +- Impressions in linear TV, including addressable linear, are outside this + table: they have no core `channel` term in 3.0. ### 5.2 Inventory composition -| Field | Type | Mutability | Description | -| :--- | :--- | :--- | :--- | -| `properties` | Object | selectable | `available[]` of `{ id, name, domains, supported_formats }`, `included[]`, `pricing`, `max_select` | -| `content_detail` | Object | seller-set | Shows, series, sections, network verticals | -| `environments` | Object | selectable | `available[]` from `web_desktop` \| `web_mobile` \| `app_mobile` \| `ctv` \| `dooh` \| `audio_streaming` | +| Field | Type | Required | Mutability | Description | +| :--- | :--- | :--- | :--- | :--- | +| `properties` | Selectable[PropertyOption] | OPTIONAL | selectable | Options of `{ id, name, domains[], supported_formats[] }` | +| `content_detail` | Object | OPTIONAL | seller-set | Shows, series, sections, network verticals | +| `environments` | Selectable[Environment] | OPTIONAL | selectable | The distribution channels and device types the line item runs on. See 5.2.1. Absent: the seller promises no environment | + +#### 5.2.1 environments + +**`Environment`** is a value of this table. Each value is the set of (distribution +channel, device type) pairs formed from its row, in AdCOM 1.0 terms. An impression +matches the value when both its distribution channel and its device type are in the +row. Device types are as in 5.1.1. + +| `environments` | Distribution channel | Device types | +| :--- | :--- | :--- | +| `web_desktop` | `Site` | Personal Computer (2) | +| `web_mobile` | `Site` | Mobile/Tablet - General (1), Phone (4), Tablet (5) | +| `app_mobile` | `App` | Mobile/Tablet - General (1), Phone (4), Tablet (5) | +| `ctv` | `Site` or `App` | Connected TV (3), Connected Device (6), Set Top Box (7) | +| `dooh` | `Dooh` | Any | + +Each `environments.available[]` value MUST match at least one impression to +which 5.1.1 gives the line item's `channel`. ### 5.3 Audience reach -| Field | Type | Mutability | Description | -| :--- | :--- | :--- | :--- | -| `audiences` | Object | selectable | `available[]` of `{ id, name, taxonomy_ref, provenance, addressable_scale }`, `included[]`, `pricing` as `delta_cpm` per option, `max_select` | -| `addressable_scale` | Integer | derived | Reachable units in the flight, recomputed against the buyer's selection. Not segment size | -| `activation_route` | Object | selectable | `available[]` from `publisher_side` \| `clean_room` \| `id_partner`, `included[]`, `pricing` | -| `buyer_supplied` | Object | supplied | `{ segments_allowed, suppression_allowed, match_rate_expected, segments[], suppression_list }`. `match_rate_expected` is the seller's stated expectation; `segments[]` and `suppression_list` are buyer-populated | +| Field | Type | Required | Mutability | Description | +| :--- | :--- | :--- | :--- | :--- | +| `audiences` | Selectable[AudienceOption] | OPTIONAL | selectable | Options of `{ id, name, taxonomy_ref, provenance, addressable_scale }`. Their `deltas` are `delta_cpm` | +| `addressable_scale` | Integer | OPTIONAL | asserted | Reachable units in the flight, recomputed against the buyer's selection. Not segment size | +| `activation_route` | Selectable[VocabularyTerm] | OPTIONAL | selectable | Core terms: `publisher_side` \| `clean_room` \| `id_partner` | +| `buyer_supplied` | BuyerSupplied | OPTIONAL | mixed | `{ segments_allowed, suppression_allowed, match_rate_expected, segments[], suppression_list }`. `segments_allowed`, `suppression_allowed` and `match_rate_expected` are `seller-set`; `match_rate_expected` is the seller's stated expectation. `segments[]` and `suppression_list` are `supplied` | ### 5.4 Creative requirements -| Field | Type | Mutability | Description | +| Field | Type | Required | Mutability | Description | +| :--- | :--- | :--- | :--- | :--- | +| `formats` | Selectable[String] | OPTIONAL | selectable | Registry format ids namespaced by channel, such as `display/300x250` and `audio/host-read-30s` | +| `specs` | Object | OPTIONAL | seller-set | As defined by the referenced format registry entry. OpenProposal does not hardcode a shape per channel | +| `third_party_tags` | Object | OPTIONAL | seller-set | Accepted wrapper types and VAST versions | +| `creative_delivery` | CreativeDelivery | OPTIONAL | seller-set | How creative reaches the seller | +| `assets[]` | Array[Asset] | OPTIONAL | mixed | Buyer-delivered | +| `materials_due` | MaterialsDue | OPTIONAL | requestable | Deadline for creative to land. A buyer MAY request a later date | +| `approval` | Object | OPTIONAL | seller-set | Review SLA and revision policy | +| `production` | Selectable[ProductionOption] | OPTIONAL | selectable | Custom units. See `ProductionOption` below | + +**`MaterialsDue`** holds exactly one key: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `at` | Instant | REQUIRED when `before_start` is absent | The deadline | +| `before_start` | Duration | REQUIRED when `at` is absent | The deadline, as a length of time before `commitment.flight.start` | + +**`Asset`** keys: + +| Key | Type | Required | Meaning | Mutability | +| :--- | :--- | :--- | :--- | :--- | +| `asset_id` | String | REQUIRED | Identity of the asset within the line item | supplied | +| `format_id` | String | REQUIRED | The `formats.available[]` id the asset is for | supplied | +| `url` | String | REQUIRED when `markup` is absent | Where the asset is, such as a tag URL with its macros unexpanded | supplied | +| `markup` | String | REQUIRED when `url` is absent | The tag or creative markup | supplied | +| `submitted_at` | Instant | OPTIONAL | When the buyer delivered it | supplied | +| `approval_status` | String | OPTIONAL | The seller's review outcome. The seller moves it | lifecycle | + +**`CreativeDelivery`** keys: + +| Key | Type | Required | Meaning | | :--- | :--- | :--- | :--- | -| `formats` | Object | selectable | `available[]` of registry format ids namespaced by channel (`display/300x250`, `audio/host-read-30s`), `included[]`, `pricing`, `max_select` | -| `specs` | Object | seller-set | As defined by the referenced format registry entry. OpenProposal does not hardcode a shape per channel | -| `third_party_tags` | Object | seller-set | Accepted wrapper types and VAST versions | -| `creative_delivery` | Object | seller-set | `{ method: buyer_hosted \| seller_hosted \| api \| platform_managed, protocol, secure_required, endpoint }` | -| `assets[]` | Array[Object] | supplied | Buyer-delivered. `{ asset_id, format_ref, url_or_tag, submitted_at, approval_status }` | -| `materials_due` | Timestamp/Offset | requestable | Deadline for creative to land. A buyer MAY request a later date | -| `approval` | Object | seller-set | Review SLA and revision policy | -| `production` | Object | selectable | Custom units. `available[]` of build options with `pricing` and `lead_time`; `{ custom_unit, build_owner, lead_time, cost }` | +| `method` | VocabularyTerm | REQUIRED | Core terms: `buyer_hosted` \| `seller_hosted` \| `api` \| `platform_managed` | +| `protocol` | String | OPTIONAL | How the creative is delivered, such as a VAST version or a JavaScript tag | +| `secure_required` | Boolean | OPTIONAL | Whether the creative must load over a secure connection | +| `endpoint` | Url | OPTIONAL | Where the buyer sends creative | + +**`ProductionOption`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `id` | String | REQUIRED | Option id | +| `custom_unit` | String | OPTIONAL | The custom unit built | +| `build_owner` | Side | OPTIONAL | Which side builds it | +| `lead_time` | Duration | OPTIONAL | Time the build needs | +| `cost` | Number | OPTIONAL | Cost of the build | + +#### 5.4.1 Creative inspection and previews + +For this section, inspection means evaluating a creative outside live campaign +delivery, including discovery, composition, submission checks and previews. A +production notification is an event report intended for a campaign's live +measurement or delivery diagnostics. + +An implementation performing inspection MUST NOT send any production +notification. This includes requesting an event URL solely to check its +availability, regardless of the HTTP method used. + +An implementation MAY retrieve creative documents, follow VAST wrappers and +inspect referenced media as needed for its checks. Permission to retrieve these +resources does not extend to firing their event URLs. Retrieval can itself be +logged by the resource owner; this requirement does not imply that inspection +produces no server activity. + +A landing-page inspection remains subject to the same notification prohibition, +including any redirect requests. The presence of a URL in VAST `` +does not establish that requesting it is safe for inspection. The inspector +MUST NOT request a destination or redirect that sends a production notification; +it MAY use a separate inspection URL or the receiving service's documented test +mode under the conditions below. + +A preview that executes creative or verification code MUST prevent that code +from sending production notifications. Synthetic events MAY be sent to test +endpoints, or through a test mode that the receiving service documents as +excluding those events from production measurement and delivery diagnostics. An +inspector's own test flag is insufficient unless the receiving service supports +it. + +Inspection failures MUST be reported through diagnostics, without firing a +production VAST `` or `verificationNotExecuted` URL. An implementation +MUST NOT report a check that was skipped or could not finish as passed. + +These requirements apply to inspection only. During live delivery, +implementations remain subject to the tracking and error-reporting requirements +of the applicable execution standard. ### 5.5 Targeting envelope -Each field declares bounds, not a value. A bare value means the field is +Each field declares bounds, not a value (§ 3.2). A bare value means the field is `seller-set`. -| Field | Type | Mutability | Description | +| Field | Type | Required | Mutability | Description | +| :--- | :--- | :--- | :--- | :--- | +| `geo` | Settable[String] | OPTIONAL | settable | `allowed[]` of geo codes the buyer may select within | +| `device` | Settable[String] | OPTIONAL | settable | `allowed[]` of device categories | +| `daypart` | Settable[Object] | OPTIONAL | settable | `allowed[]` of time windows | +| `frequency_cap` | FrequencyCap | OPTIONAL | settable | See below | + +**`FrequencyCap`** keys: + +| Key | Type | Required | Meaning | | :--- | :--- | :--- | :--- | -| `geo` | Object | settable | `allowed[]` of geo codes the buyer may select within | -| `device` | Object | settable | `allowed[]` of device categories | -| `daypart` | Object | settable | `allowed[]` of time windows | -| `frequency_cap` | Object | settable | `{ max: { min, max }, period, basis: device \| household \| listener }`. Basis matters: CTV typically caps at household, audio at listener | +| `count` | Range[Integer] | REQUIRED | The bounds on the cap | +| `period` | TimeUnit | REQUIRED | The bucket the count runs over | +| `per` | Enum | REQUIRED | `device` \| `household` \| `listener`. Who is capped. It matters: CTV typically caps at household, audio at listener | ### 5.6 Commercial terms -| Field | Type | Mutability | Description | -| :--- | :--- | :--- | :--- | -| `pricing[]` | Array[Object] | mixed | See below | -| `pricing[].cost_method` | Enum | seller-set | CPM, CPC, flat, etc. | -| `pricing[].gross_rate` | Number | seller-set | The list rate the buyer pays | -| `pricing[].agreed_rate` | Number | derived | The transacted rate after selections and any agreed request | -| `pricing[].seller_rate` | Number | seller-set | What the publisher receives | -| `pricing[].price_valid_until` | Timestamp | seller-set | How long the posted rate holds. Distinct from the proposal's `valid_until` | -| `pricing[].currency` | String | seller-set | ISO-4217 | -| `pricing[].floor` | Number | seller-set | OPTIONAL. The lowest rate the seller will consider. Enables a `requestable` counter to be evaluated without a round trip | -| `availability` | Object | derived | `{ unit, basis, as_of, granularity, type: forecast \| reservable, quantity }`. Recomputed against the buyer's selection | -| `commitment_bounds` | Object | seller-set | The envelope inside which a buyer may commit: `{ min_impressions, max_impressions, min_budget, max_budget }` | -| `commitment` | Object | settable | **The buyer's actual commitment.** See 5.6.1 | -| `hold_status` | Object | requestable | See 5.6.2 | -| `min_spend` | Number | seller-set | Minimum spend for this line item | -| `cancellation_policy` | Object | selectable | `available[]` of cancellation rights with `pricing` per option | -| `exclusivity` | Object | selectable | `available[]` of exclusivity terms with `pricing` | -| `creative_policy` | Object | seller-set | Content and category restrictions on creative | +| Field | Type | Required | Mutability | Description | +| :--- | :--- | :--- | :--- | :--- | +| `pricing[]` | Array[PricingEntry] | REQUIRED | mixed | See below | +| `pricing[].cost_method` | VocabularyTerm | REQUIRED | seller-set | The billable unit. Core terms: `CPM` \| `CPC` \| `FLAT` | +| `pricing[].gross_rate` | Number | REQUIRED | seller-set | The list rate the buyer pays | +| `pricing[].agreed_rate` | Number | OPTIONAL | derived | The transacted rate after selections and any agreed request | +| `pricing[].seller_rate` | Number | OPTIONAL | seller-set | What the publisher receives | +| `pricing[].price_valid_until` | Instant | OPTIONAL | seller-set | How long the posted rate holds. Distinct from the proposal's `offer_window.end` | +| `pricing[].currency` | String | REQUIRED | seller-set | ISO-4217 | +| `pricing[].floor` | Number | OPTIONAL | seller-set | The lowest rate the seller will consider. Enables a `requestable` counter to be evaluated without a round trip | +| `availability` | Object | OPTIONAL | asserted | `{ unit, basis, as_of, granularity, type: forecast \| reservable, quantity }`, with `as_of` an `Instant` and `granularity` a `CalendarUnit`. Recomputed against the buyer's selection | +| `commitment_bounds` | Object | OPTIONAL | seller-set | The envelope inside which a buyer may commit: `{ min_impressions, max_impressions, min_budget, max_budget }` | +| `commitment` | Commitment | OPTIONAL | settable | **The buyer's actual commitment.** See 5.6.1 | +| `hold_status` | HoldStatus | OPTIONAL | mixed | See 5.6.2 | +| `min_spend` | Number | OPTIONAL | seller-set | Minimum spend for this line item | +| `cancellation_policy` | Selectable[CancellationOption] | OPTIONAL | selectable | The cancellation rights the seller offers | +| `exclusivity` | Selectable[ExclusivityTerm] | OPTIONAL | selectable | The exclusivity terms the seller offers | +| `creative_policy` | Object | OPTIONAL | seller-set | Content and category restrictions on creative | +| `requires` | Requires | OPTIONAL | seller-set | Conditions that must hold in the same commitment for this line item to be committable. See 5.6.3 | #### 5.6.1 commitment Absent on a published standard proposal. Populated during composition. Frozen when status becomes `agreed`. -| Key | Required | Meaning | -| :--- | :--- | :--- | -| `basis` | REQUIRED | `spend` \| `units` | -| `amount` | REQUIRED | The figure committed | -| `currency` | REQUIRED when `basis = spend` | ISO-4217 | -| `flight_start` | REQUIRED | The buyer's campaign start. **Distinct from `valid_from`** | -| `flight_end` | REQUIRED | The buyer's campaign end. **Distinct from `valid_until`** | -| `derived_units` | derived | Computed from `amount`, `agreed_rate` and `cost_method`. Informative, not binding | -| `idempotency_key` | REQUIRED | Buyer-generated, unique per commitment attempt | +**`CommitmentAmount`** is the committed figure. `basis` means only this. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `basis` | Enum | REQUIRED | `spend` \| `units` | +| `amount` | Number | REQUIRED | The figure committed | + +**`Commitment`** holds the keys of `CommitmentAmount` and these: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `currency` | String | REQUIRED when `basis` is `spend` | ISO-4217 | +| `flight` | Interval | REQUIRED | The buyer's campaign. **Distinct from `offer_window`** | +| `derived_units` | Integer | OPTIONAL | Computed from `amount`, `agreed_rate` and `cost_method`. Informative, not binding | +| `idempotency_key` | String | REQUIRED | Buyer-generated, unique per commitment attempt | A request carrying an `idempotency_key` already seen MUST return the original result and MUST NOT create a second commitment. #### 5.6.2 hold_status -| Key | Meaning | -| :--- | :--- | -| `hold_available` | Whether this line item may be held | -| `hold_duration` | ISO-8601 duration the hold runs for | -| `hold_scope` | `line_item` \| `proposal` | -| `state` | `none` \| `requested` \| `held` \| `expired` \| `released` \| `converted` | -| `hold_expires_at` | Absolute timestamp, set when the hold is granted | +**`HoldStatus`** keys: + +| Key | Type | Required | Meaning | Mutability | +| :--- | :--- | :--- | :--- | :--- | +| `hold_available` | Boolean | OPTIONAL | Whether this line item may be held | seller-set | +| `hold_duration` | Duration | OPTIONAL | How long the hold runs | seller-set | +| `hold_scope` | Enum | OPTIONAL | `line_item` \| `proposal` | seller-set | +| `state` | Enum | OPTIONAL | `none` \| `requested` \| `held` \| `expired` \| `released` \| `converted`, moved as below | lifecycle | +| `hold_expires_at` | Instant | OPTIONAL | Set when the hold is granted | lifecycle | Transitions: `none` → `requested` → `held` → (`expired` | `released` | `converted`). `converted` is terminal and occurs when the line item reaches `agreed`. +#### 5.6.3 requires + +**`Requires`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `all_of[]` | Array[String] | OPTIONAL | Line items that MUST all be in the same commitment | +| `any_of[]` | Array[String] | OPTIONAL | Line items of which at least one MUST be in the same commitment | +| `min_commitments[]` | Array[MinCommitment] | OPTIONAL | Purchase-level thresholds: `{ line_item_id, basis, amount, currency }` | + +```yaml +requires: + all_of: ["business-daily-display", "weather-app-ctv"] + min_commitments: + - { line_item_id: "business-daily-display", basis: spend, amount: 50000.00, currency: USD } +``` + +A bonus line item is a line item with `requires` and its own pricing, such as +a discounted `gross_rate` or `cost_method: FLAT` at 0. A conditional discount +is modelled as a separate line item that `requires` unlocks, not as a price +that changes according to the other line items selected. Every price remains +self-contained. + +- References in `requires` MUST point to line items in the same proposal and + version. +- A seller MUST reject a commitment that includes a line item whose `requires` + is unmet. This check MUST be deterministic. +- If a line item referenced in `requires` becomes `sold_out` or `withdrawn` + before commitment, the dependent line item becomes non-committable. After + `agreed`, cancellation of a referenced line item is governed by the + dependent line item's `cancellation_policy`. +- A seller MUST reject a proposal at publication if `requires` references + form a cycle. + ### 5.7 Guarantees -| Field | Type | Mutability | Description | +| Field | Type | Required | Mutability | Description | +| :--- | :--- | :--- | :--- | :--- | +| `committed_metrics[]` | Array[CommittedMetric] | OPTIONAL | requestable | `{ metric, value, basis }` tuples — `viewable_rate` for display, `completion_rate` for video/audio/CTV, an audience-modelled metric for DOOH. Seller declares a ceiling; a buyer MAY request above it | +| `measurement_source` | Selectable[MeasurementOption] | OPTIONAL | selectable | See `MeasurementOption` below. Lets a buyer transact in its own currency | +| `shortfall_remedy` | Selectable[VocabularyTerm] | OPTIONAL | selectable | Core terms: `makegood` \| `credit` \| `none`, plus `remedy_terms` | +| `reporting` | Selectable[ReportingOption] | OPTIONAL | selectable | See `ReportingOption` below | + +**`MeasurementOption`** keys: + +| Key | Type | Required | Meaning | | :--- | :--- | :--- | :--- | -| `committed_metrics[]` | Array[Object] | requestable | `{ metric, value, basis }` tuples — `viewable_rate` for display, `completion_rate` for video/audio/CTV, an audience-modelled metric for DOOH. Seller declares a ceiling; a buyer MAY request above it | -| `measurement_source` | Object | selectable | `available[]` of `{ vendor, attribution_window, qualifier }` with `pricing`. Lets a buyer transact in its own currency | -| `shortfall_remedy` | Object | selectable | `available[]` from `makegood` \| `credit` \| `none`, plus `remedy_terms` | -| `reporting` | Object | selectable | `available[]` of `{ feed_type: log_level \| aggregate \| none, cadence, delivery_mechanism, metrics_provided[], latency }` with `pricing` | +| `vendor` | String | REQUIRED | The measurement provider | +| `attribution_window` | Duration | OPTIONAL | How long after exposure the provider attributes an event | +| `qualifier` | String | OPTIONAL | A product or method of the provider | -### 5.8 Transaction eligibility +**`ReportingOption`** keys: -| Field | Type | Mutability | Description | +| Key | Type | Required | Meaning | | :--- | :--- | :--- | :--- | -| `visibility` | Enum | seller-set | `public` \| `registry_gated` \| `named` | -| `required_credentials[]` | Array[Object] | seller-set | `{ type: iab_registry \| dsp_seat \| custom, scheme: api_key \| oauth2_client_credentials \| jwt_keypair \| oidc_federated \| none, issuer, id_required }` | -| `eligible_bidders[]` | Array[String] | seller-set | Permitted seat or buyer ids | -| `terms_acceptance_required` | Boolean | seller-set | Whether legal terms must be accepted before assent | +| `feed_type` | Enum | REQUIRED | `log_level` \| `aggregate` | +| `cadence` | TimeUnit | OPTIONAL | How often a report arrives | +| `delivery_mechanism` | VocabularyTerm | OPTIONAL | How a report arrives. Core terms: `api` \| `s3` | +| `provided_metrics[]` | Array[String] | OPTIONAL | The metrics a report holds | +| `latency` | Duration | OPTIONAL | How long after the period a report arrives | + +### 5.8 Transaction eligibility + +| Field | Type | Required | Mutability | Description | +| :--- | :--- | :--- | :--- | :--- | +| `visibility` | Enum | OPTIONAL | seller-set | `public` \| `registry_gated` \| `named` | +| `required_credentials[]` | Array[Object] | OPTIONAL | seller-set | `{ type: iab_registry \| dsp_seat \| custom, scheme: api_key \| oauth2_client_credentials \| jwt_keypair \| oidc_federated \| none, issuer, id_required }` | +| `eligible_bidders[]` | Array[String] | OPTIONAL | seller-set | Permitted seat or buyer ids | +| `terms_acceptance_required` | Boolean | OPTIONAL | seller-set | Whether legal terms must be accepted before assent | ### 5.9 Execution -| Field | Type | Mutability | Description | -| :--- | :--- | :--- | :--- | -| `buying_route` | Enum | seller-set | `DIRECT_PUBLISHER` \| `PUBLISHER_AD_SERVER` \| `SSP_PLATFORM` \| `DSP` \| `SEARCH_SOCIAL` | -| `transaction_mechanism` | Object | selectable | `available[]` from `DIRECT_IO` \| `PROGRAMMATIC_GUARANTEED` \| `PREFERRED_DEAL` \| `PRIVATE_AUCTION` \| `OPEN_AUCTION`. A seller MAY offer the same inventory through more than one | -| `serving_mode` | Enum | seller-set | `client_side` \| `ssai` \| `dai` | -| `deal_id_issuance` | String | derived | When and how the deal id is minted. Issued at commitment time | -| `buyer_requirements` | Object | mixed | `{ seat_required, dsp_allowed[] }` are seller-set; `{ dsp_seat_id, dsp_id, seat_owner_entity }` are supplied | -| `serving_platform` | String | seller-set | Where the line item serves | +| Field | Type | Required | Mutability | Description | +| :--- | :--- | :--- | :--- | :--- | +| `buying_route` | Enum | OPTIONAL | seller-set | `DIRECT_PUBLISHER` \| `PUBLISHER_AD_SERVER` \| `SSP_PLATFORM` \| `DSP` \| `SEARCH_SOCIAL` | +| `transaction_mechanism` | Selectable[TransactionMechanism] | REQUIRED | selectable | See `TransactionMechanism` below. A seller MAY offer the same inventory through more than one | +| `serving_mode` | VocabularyTerm | OPTIONAL | seller-set | Core terms: `client_side` \| `ssai` \| `dai` | +| `deal_id_issuance` | String | OPTIONAL | derived | When and how the deal id is minted. Issued at commitment time | +| `buyer_requirements` | BuyerRequirements | OPTIONAL | mixed | `{ seat_required, dsp_allowed[] }` are `seller-set`; `{ dsp_seat_id, dsp_id, seat_owner_entity }` are `supplied` | +| `serving_platform` | String | OPTIONAL | seller-set | Where the line item serves | + +**`TransactionMechanism`** is `direct_io` \| `programmatic_guaranteed` \| +`preferred_deal` \| `private_auction` \| `open_auction`. ### 5.10 External references and settlement -| Field | Type | Mutability | Description | -| :--- | :--- | :--- | :--- | -| `external_references[]` | Array[Object] | derived | `{ system, namespace, id, role }`. Replaces platform-specific key names. A buyer-owned reference such as a Prisma `io_id` is supplied | -| `correlation_id` | String | derived | One identity for this line item's commitment, carried across every system it touches | -| `billing` | Object | seller-set | `{ supplier_id, billing_supplier_id, payment_terms, invoicing_party }`. Who delivers is not always who invoices | -| `supported_order_systems[]` | Array[String] | seller-set | Declared up front so the agent knows it will reconcile | +| Field | Type | Required | Mutability | Description | +| :--- | :--- | :--- | :--- | :--- | +| `external_references[]` | Array[ExternalReference] | OPTIONAL | recorded | `{ system, namespace, id, role }`. Replaces platform-specific key names. The party that learns an id writes the entry | +| `correlation_id` | String | OPTIONAL | derived | One identity for this line item's commitment, carried across every system it touches | +| `billing` | Object | OPTIONAL | seller-set | `{ supplier_id, billing_supplier_id, payment_terms, invoicing_party }`. Who delivers is not always who invoices | +| `supported_order_systems[]` | Array[String] | OPTIONAL | seller-set | Declared up front so the agent knows it will reconcile | --- @@ -359,22 +793,37 @@ Runnable copies of every example below live in [`examples/`](../examples/). ### 6.1 Proposal -See [`examples/proposal.yaml`](../examples/proposal.yaml). +See [`examples/proposal.yaml`](../examples/proposal.yaml). Its line items are +shown in their own files, §§ 6.2 to 6.5. ### 6.2 Display line item — fully worked -Shows `selectable` audiences with per-option pricing, a catalog reference, a -buyer commitment, and `settable` bounds. See +Shows `selectable` audiences with per-option pricing, a buyer commitment, and +`settable` bounds. See [`examples/line-item-display.yaml`](../examples/line-item-display.yaml). ### 6.3 CTV line item See [`examples/line-item-ctv.yaml`](../examples/line-item-ctv.yaml). +Inspecting this line item's VAST creative is subject to § 5.4.1. + ### 6.4 Audio line item See [`examples/line-item-audio.yaml`](../examples/line-item-audio.yaml). +### 6.5 Bonus line item + +A CTV line item unlocked by buying the display and CTV line items together, +priced on its own. See +[`examples/line-item-ctv-bonus.yaml`](../examples/line-item-ctv-bonus.yaml). + +### 6.6 Publication manifest + +A manifest (§ 2.3) listing the public line items of the example proposal. The +display line item is `registry_gated`, so it is not listed. See +[`examples/manifest.json`](../examples/manifest.json). + --- ## 7. Why this split @@ -382,15 +831,15 @@ See [`examples/line-item-audio.yaml`](../examples/line-item-audio.yaml). - **No inheritance mechanism to specify or debug.** Fields live at exactly one level, so there is no "overridable unless restated" rule to maintain. - **The proposal reads as a pitch, not a schema dump.** `description`, - `specifications`, `best_for` and `seasonality_notes` are what a model needs to + `specifications`, `best_for[]` and `seasonality_notes` are what a model needs to decide whether to look further. -- **Every line item is independently transactable, in exactly one channel.** A - buying agent can hand a single line item to a downstream system without - resolving proposal-level defaults or cross-channel ambiguity. -- **Channel support cost one new field and one generalisation** — `channel`, and - `committed_metrics[]` as tuples — rather than a schema fork per channel. The - format registry, not OpenProposal, carries what is channel-specific about - creative shape. +- **Every line item states its own terms, in exactly one channel.** A + buying agent can hand a line item, with the proposal's identity, to a downstream + system, with no proposal-level defaults or cross-channel ambiguity to resolve. +- **Channels share one object, not a schema fork per channel.** `channel` is the + discriminator, and `committed_metrics[]` names the metric in each tuple. The + format registry, through `specs`, carries what is channel-specific about creative + shape. - **Composition resolves without a round trip.** Because the seller declares the option set and its pricing up front, a buyer agent performs constraint satisfaction rather than negotiation. Only genuinely out-of-envelope asks @@ -406,8 +855,9 @@ see [docs/open-questions.md](../docs/open-questions.md). 1. **Does per-field mutability belong in 3.0?** Sections 3 and 5 make composition part of the object. The alternative is a catalog format where a buyer selects whole line items and everything else is a round trip. -2. **Do seller catalogs (`catalog_ref`) belong in 3.0**, or should the object - stay fully inline until a later version? +2. **Is a fetchable reference to a seller catalog needed?** The object is fully + inline: a seller reuses catalog entries in its own systems, and a summary lists + what § 2.1 names. 3. **Cross-channel guarantee comparability is unsolved.** Once one line item guarantees `viewable_rate` and another `completion_rate`, there is no normalised way for a buying agent to compare or optimise across them. Likely @@ -415,95 +865,97 @@ see [docs/open-questions.md](../docs/open-questions.md). 4. **The format registry has no entries for `audio/*`, `ctv/*` or `dooh/*`.** `specs` delegates to a registry that cannot currently answer for half the declared channels. -5. **Is a `min_line_items` field needed** on the proposal to require a bundle - purchase, or is that left to buyer discretion? -6. **Should `payment_terms` and `invoicing_party` be catalog references** rather - than repeated on every line item, given they are usually identical across a - seller's shelf? -7. **`environments[]` needs validation against real inventory.** The enum is - proposed, not derived from implementation experience. +5. **Do `linked_execution` and `requires` cover bundle purchases**, or does the + proposal also need a `min_line_items` field? +6. **Should `billing.payment_terms` and `billing.invoicing_party` be repeated on + every line item,** given they are usually identical across a seller's shelf? +7. **`environments.available[]` needs validation against real inventory.** The + list is proposed, not derived from implementation experience. --- ## Appendix A — Field index -Every field in the specification, in document order, with its mutability -marker. Generated from the body of this document; **if the two disagree, the +Every field in the specification, in document order, with its type, presence, +and mutability marker. Generated from the body of this document; **if the two disagree, the body is normative.** -| Field | Section | Type | Marker | -| :--- | :--- | :--- | :--- | -| `proposal_id` | 4.1 Identity and lifecycle | String | immutable | -| `version` | 4.1 Identity and lifecycle | Integer | derived | -| `seller_id` | 4.1 Identity and lifecycle | String | immutable | -| `seller_name` | 4.1 Identity and lifecycle | String | immutable | -| `type` | 4.1 Identity and lifecycle | Enum | immutable | -| `status` | 4.1 Identity and lifecycle | Enum | derived | -| `assent` | 4.1 Identity and lifecycle | Object | derived | -| `valid_from` · `valid_until` | 4.1 Identity and lifecycle | Timestamp | seller-set | -| `brief_ref` | 4.1 Identity and lifecycle | String | supplied | -| `negotiation_history[]` | 4.1 Identity and lifecycle | Array[Object] | derived | -| `description` | 4.2 Agent comprehension | String | seller-set | -| `specifications` | 4.2 Agent comprehension | String/Object | seller-set | -| `best_for[]` | 4.2 Agent comprehension | Array[String] | seller-set | -| `not_suitable_for[]` | 4.2 Agent comprehension | Array[String] | seller-set | -| `seasonality_notes` | 4.2 Agent comprehension | String | seller-set | -| `line_item_id` | 5.1 Identity | String | immutable | -| `status` | 5.1 Identity | Enum | derived | -| `channel` | 5.1 Identity | Enum | immutable | -| `properties` | 5.2 Inventory composition | Object | selectable | -| `content_detail` | 5.2 Inventory composition | Object | seller-set | -| `environments` | 5.2 Inventory composition | Object | selectable | -| `audiences` | 5.3 Audience reach | Object | selectable | -| `addressable_scale` | 5.3 Audience reach | Integer | derived | -| `activation_route` | 5.3 Audience reach | Object | selectable | -| `buyer_supplied` | 5.3 Audience reach | Object | supplied | -| `formats` | 5.4 Creative requirements | Object | selectable | -| `specs` | 5.4 Creative requirements | Object | seller-set | -| `third_party_tags` | 5.4 Creative requirements | Object | seller-set | -| `creative_delivery` | 5.4 Creative requirements | Object | seller-set | -| `assets[]` | 5.4 Creative requirements | Array[Object] | supplied | -| `materials_due` | 5.4 Creative requirements | Timestamp/Offset | requestable | -| `approval` | 5.4 Creative requirements | Object | seller-set | -| `production` | 5.4 Creative requirements | Object | selectable | -| `geo` | 5.5 Targeting envelope | Object | settable | -| `device` | 5.5 Targeting envelope | Object | settable | -| `daypart` | 5.5 Targeting envelope | Object | settable | -| `frequency_cap` | 5.5 Targeting envelope | Object | settable | -| `pricing[]` | 5.6 Commercial terms | Array[Object] | mixed | -| `pricing[].cost_method` | 5.6 Commercial terms | Enum | seller-set | -| `pricing[].gross_rate` | 5.6 Commercial terms | Number | seller-set | -| `pricing[].agreed_rate` | 5.6 Commercial terms | Number | derived | -| `pricing[].seller_rate` | 5.6 Commercial terms | Number | seller-set | -| `pricing[].price_valid_until` | 5.6 Commercial terms | Timestamp | seller-set | -| `pricing[].currency` | 5.6 Commercial terms | String | seller-set | -| `pricing[].floor` | 5.6 Commercial terms | Number | seller-set | -| `availability` | 5.6 Commercial terms | Object | derived | -| `commitment_bounds` | 5.6 Commercial terms | Object | seller-set | -| `commitment` | 5.6 Commercial terms | Object | settable | -| `hold_status` | 5.6 Commercial terms | Object | requestable | -| `min_spend` | 5.6 Commercial terms | Number | seller-set | -| `cancellation_policy` | 5.6 Commercial terms | Object | selectable | -| `exclusivity` | 5.6 Commercial terms | Object | selectable | -| `creative_policy` | 5.6 Commercial terms | Object | seller-set | -| `committed_metrics[]` | 5.7 Guarantees | Array[Object] | requestable | -| `measurement_source` | 5.7 Guarantees | Object | selectable | -| `shortfall_remedy` | 5.7 Guarantees | Object | selectable | -| `reporting` | 5.7 Guarantees | Object | selectable | -| `visibility` | 5.8 Transaction eligibility | Enum | seller-set | -| `required_credentials[]` | 5.8 Transaction eligibility | Array[Object] | seller-set | -| `eligible_bidders[]` | 5.8 Transaction eligibility | Array[String] | seller-set | -| `terms_acceptance_required` | 5.8 Transaction eligibility | Boolean | seller-set | -| `buying_route` | 5.9 Execution | Enum | seller-set | -| `transaction_mechanism` | 5.9 Execution | Object | selectable | -| `serving_mode` | 5.9 Execution | Enum | seller-set | -| `deal_id_issuance` | 5.9 Execution | String | derived | -| `buyer_requirements` | 5.9 Execution | Object | mixed | -| `serving_platform` | 5.9 Execution | String | seller-set | -| `external_references[]` | 5.10 External references and settlement | Array[Object] | derived | -| `correlation_id` | 5.10 External references and settlement | String | derived | -| `billing` | 5.10 External references and settlement | Object | seller-set | -| `supported_order_systems[]` | 5.10 External references and settlement | Array[String] | seller-set | +| Field | Section | Type | Required | Marker | +| :--- | :--- | :--- | :--- | :--- | +| `proposal_id` | 4.1 Identity and lifecycle | String | REQUIRED | immutable | +| `version` | 4.1 Identity and lifecycle | Integer | REQUIRED | derived | +| `seller_id` | 4.1 Identity and lifecycle | String | REQUIRED | immutable | +| `seller_name` | 4.1 Identity and lifecycle | String | OPTIONAL | immutable | +| `type` | 4.1 Identity and lifecycle | Enum | REQUIRED | immutable | +| `status` | 4.1 Identity and lifecycle | Enum | REQUIRED | derived | +| `assent` | 4.1 Identity and lifecycle | Object | OPTIONAL | derived | +| `offer_window` | 4.1 Identity and lifecycle | Interval | OPTIONAL | seller-set | +| `brief_id` | 4.1 Identity and lifecycle | String | REQUIRED when `type` is `custom` | immutable | +| `negotiation_history[]` | 4.1 Identity and lifecycle | Array[HistoryEntry] | OPTIONAL | recorded | +| `line_items[]` | 4.1 Identity and lifecycle | Array[LineItem] | REQUIRED | seller-set | +| `linked_execution` | 4.1 Identity and lifecycle | Enum | OPTIONAL | seller-set | +| `description` | 4.2 Agent comprehension | String | REQUIRED | seller-set | +| `specifications` | 4.2 Agent comprehension | String | OPTIONAL | seller-set | +| `best_for[]` | 4.2 Agent comprehension | Array[String] | OPTIONAL | seller-set | +| `not_suitable_for[]` | 4.2 Agent comprehension | Array[String] | OPTIONAL | seller-set | +| `seasonality_notes` | 4.2 Agent comprehension | String | OPTIONAL | seller-set | +| `line_item_id` | 5.1 Identity | String | REQUIRED | immutable | +| `status` | 5.1 Identity | Enum | REQUIRED | lifecycle | +| `channel` | 5.1 Identity | MediaChannel | REQUIRED | immutable | +| `properties` | 5.2 Inventory composition | Selectable[PropertyOption] | OPTIONAL | selectable | +| `content_detail` | 5.2 Inventory composition | Object | OPTIONAL | seller-set | +| `environments` | 5.2 Inventory composition | Selectable[Environment] | OPTIONAL | selectable | +| `audiences` | 5.3 Audience reach | Selectable[AudienceOption] | OPTIONAL | selectable | +| `addressable_scale` | 5.3 Audience reach | Integer | OPTIONAL | asserted | +| `activation_route` | 5.3 Audience reach | Selectable[VocabularyTerm] | OPTIONAL | selectable | +| `buyer_supplied` | 5.3 Audience reach | BuyerSupplied | OPTIONAL | mixed | +| `formats` | 5.4 Creative requirements | Selectable[String] | OPTIONAL | selectable | +| `specs` | 5.4 Creative requirements | Object | OPTIONAL | seller-set | +| `third_party_tags` | 5.4 Creative requirements | Object | OPTIONAL | seller-set | +| `creative_delivery` | 5.4 Creative requirements | CreativeDelivery | OPTIONAL | seller-set | +| `assets[]` | 5.4 Creative requirements | Array[Asset] | OPTIONAL | mixed | +| `materials_due` | 5.4 Creative requirements | MaterialsDue | OPTIONAL | requestable | +| `approval` | 5.4 Creative requirements | Object | OPTIONAL | seller-set | +| `production` | 5.4 Creative requirements | Selectable[ProductionOption] | OPTIONAL | selectable | +| `geo` | 5.5 Targeting envelope | Settable[String] | OPTIONAL | settable | +| `device` | 5.5 Targeting envelope | Settable[String] | OPTIONAL | settable | +| `daypart` | 5.5 Targeting envelope | Settable[Object] | OPTIONAL | settable | +| `frequency_cap` | 5.5 Targeting envelope | FrequencyCap | OPTIONAL | settable | +| `pricing[]` | 5.6 Commercial terms | Array[PricingEntry] | REQUIRED | mixed | +| `pricing[].cost_method` | 5.6 Commercial terms | VocabularyTerm | REQUIRED | seller-set | +| `pricing[].gross_rate` | 5.6 Commercial terms | Number | REQUIRED | seller-set | +| `pricing[].agreed_rate` | 5.6 Commercial terms | Number | OPTIONAL | derived | +| `pricing[].seller_rate` | 5.6 Commercial terms | Number | OPTIONAL | seller-set | +| `pricing[].price_valid_until` | 5.6 Commercial terms | Instant | OPTIONAL | seller-set | +| `pricing[].currency` | 5.6 Commercial terms | String | REQUIRED | seller-set | +| `pricing[].floor` | 5.6 Commercial terms | Number | OPTIONAL | seller-set | +| `availability` | 5.6 Commercial terms | Object | OPTIONAL | asserted | +| `commitment_bounds` | 5.6 Commercial terms | Object | OPTIONAL | seller-set | +| `commitment` | 5.6 Commercial terms | Commitment | OPTIONAL | settable | +| `hold_status` | 5.6 Commercial terms | HoldStatus | OPTIONAL | mixed | +| `min_spend` | 5.6 Commercial terms | Number | OPTIONAL | seller-set | +| `cancellation_policy` | 5.6 Commercial terms | Selectable[CancellationOption] | OPTIONAL | selectable | +| `exclusivity` | 5.6 Commercial terms | Selectable[ExclusivityTerm] | OPTIONAL | selectable | +| `creative_policy` | 5.6 Commercial terms | Object | OPTIONAL | seller-set | +| `requires` | 5.6 Commercial terms | Requires | OPTIONAL | seller-set | +| `committed_metrics[]` | 5.7 Guarantees | Array[CommittedMetric] | OPTIONAL | requestable | +| `measurement_source` | 5.7 Guarantees | Selectable[MeasurementOption] | OPTIONAL | selectable | +| `shortfall_remedy` | 5.7 Guarantees | Selectable[VocabularyTerm] | OPTIONAL | selectable | +| `reporting` | 5.7 Guarantees | Selectable[ReportingOption] | OPTIONAL | selectable | +| `visibility` | 5.8 Transaction eligibility | Enum | OPTIONAL | seller-set | +| `required_credentials[]` | 5.8 Transaction eligibility | Array[Object] | OPTIONAL | seller-set | +| `eligible_bidders[]` | 5.8 Transaction eligibility | Array[String] | OPTIONAL | seller-set | +| `terms_acceptance_required` | 5.8 Transaction eligibility | Boolean | OPTIONAL | seller-set | +| `buying_route` | 5.9 Execution | Enum | OPTIONAL | seller-set | +| `transaction_mechanism` | 5.9 Execution | Selectable[TransactionMechanism] | REQUIRED | selectable | +| `serving_mode` | 5.9 Execution | VocabularyTerm | OPTIONAL | seller-set | +| `deal_id_issuance` | 5.9 Execution | String | OPTIONAL | derived | +| `buyer_requirements` | 5.9 Execution | BuyerRequirements | OPTIONAL | mixed | +| `serving_platform` | 5.9 Execution | String | OPTIONAL | seller-set | +| `external_references[]` | 5.10 External references and settlement | Array[ExternalReference] | OPTIONAL | recorded | +| `correlation_id` | 5.10 External references and settlement | String | OPTIONAL | derived | +| `billing` | 5.10 External references and settlement | Object | OPTIONAL | seller-set | +| `supported_order_systems[]` | 5.10 External references and settlement | Array[String] | OPTIONAL | seller-set | --- @@ -511,22 +963,24 @@ body is normative.** | Marker | Count | Meaning | Fields | | :--- | :--- | :--- | :--- | -| immutable | 6 | Set once at creation. Neither party may change it. | `proposal_id`, `seller_id`, `seller_name`, `type`, `line_item_id`, `channel` | -| seller-set | 29 | Seller sets and may revise while `draft` or `published`. | `valid_from` · `valid_until`, `description`, `specifications`, `best_for[]`, `not_suitable_for[]`, `seasonality_notes`, `content_detail`, `specs`, `third_party_tags`, `creative_delivery`, `approval`, `pricing[].cost_method`, `pricing[].gross_rate`, `pricing[].seller_rate`, `pricing[].price_valid_until`, `pricing[].currency`, `pricing[].floor`, `commitment_bounds`, `min_spend`, `creative_policy`, `visibility`, `required_credentials[]`, `eligible_bidders[]`, `terms_acceptance_required`, `buying_route`, `serving_mode`, `serving_platform`, `billing`, `supported_order_systems[]` | -| selectable | 12 | Buyer chooses from a declared set. Resolves without assent. | `properties`, `environments`, `audiences`, `activation_route`, `formats`, `production`, `cancellation_policy`, `exclusivity`, `measurement_source`, `shortfall_remedy`, `reporting`, `transaction_mechanism` | -| settable | 5 | Buyer sets a value within declared bounds. Resolves without assent. | `geo`, `device`, `daypart`, `frequency_cap`, `commitment` | -| supplied | 3 | Buyer provides data or an artifact. | `brief_ref`, `buyer_supplied`, `assets[]` | -| requestable | 3 | Buyer may ask outside the declared space. Requires seller assent. | `materials_due`, `hold_status`, `committed_metrics[]` | -| derived | 11 | Computed. Neither party sets it directly. | `version`, `status` (proposal), `assent`, `negotiation_history[]`, `status` (line item), `addressable_scale`, `pricing[].agreed_rate`, `availability`, `deal_id_issuance`, `external_references[]`, `correlation_id` | -| mixed | 2 | Sub-fields carry different markers; see the section. | `pricing[]`, `buyer_requirements` | +| immutable | 7 | Set once at creation. Neither party may change it. | `proposal_id`, `seller_id`, `seller_name`, `type`, `brief_id`, `line_item_id`, `channel` | +| seller-set | 32 | The seller sets it and MAY revise it while status is `draft` or `published`. The buyer cannot change it directly. | `offer_window`, `line_items[]`, `linked_execution`, `description`, `specifications`, `best_for[]`, `not_suitable_for[]`, `seasonality_notes`, `content_detail`, `specs`, `third_party_tags`, `creative_delivery`, `approval`, `pricing[].cost_method`, `pricing[].gross_rate`, `pricing[].seller_rate`, `pricing[].price_valid_until`, `pricing[].currency`, `pricing[].floor`, `commitment_bounds`, `min_spend`, `creative_policy`, `requires`, `visibility`, `required_credentials[]`, `eligible_bidders[]`, `terms_acceptance_required`, `buying_route`, `serving_mode`, `serving_platform`, `billing`, `supported_order_systems[]` | +| selectable | 12 | The buyer chooses from a seller-declared set. Each option MAY carry a price delta. Resolves without a round trip. | `properties`, `environments`, `audiences`, `activation_route`, `formats`, `production`, `cancellation_policy`, `exclusivity`, `measurement_source`, `shortfall_remedy`, `reporting`, `transaction_mechanism` | +| settable | 5 | The buyer supplies a value within seller-declared bounds. Resolves without a round trip. | `geo`, `device`, `daypart`, `frequency_cap`, `commitment` | +| requestable | 2 | The buyer MAY ask for a value outside the declared space. Requires explicit seller assent. | `materials_due`, `committed_metrics[]` | +| derived | 6 | Computed by a rule this specification states. Neither party sets it directly. | `version`, `status` (proposal), `assent`, `pricing[].agreed_rate`, `deal_id_issuance`, `correlation_id` | +| lifecycle | 1 | Live state of the object. The party its section names moves it between the stated values. Not a term. | `status` (line item) | +| recorded | 2 | Written by the party that acted. Append-only: an entry is never edited or removed. Not a term. | `negotiation_history[]`, `external_references[]` | +| asserted | 2 | The seller states it from data the buyer can't recompute. Not a term, and it changes without a new version. | `addressable_scale`, `availability` | +| mixed | 5 | The keys of the object carry different markers: in the Mutability column of its key table, in its lead-in for composed keys, or on its rows for `pricing[]`. | `buyer_supplied`, `assets[]`, `pricing[]`, `hold_status`, `buyer_requirements` | ### What the distribution says -Of 71 fields, **23 are touched by the buyer** — 12 `selectable`, 5 `settable`, 3 -`supplied` and 3 `requestable`. The remaining 48 are `seller-set`, `immutable`, -`derived` or `mixed`. +Of 74 fields, **19 are touched by the buyer** — 12 `selectable`, 5 `settable`, 0 +`supplied` and 2 `requestable`. The remaining 55 are `seller-set`, `immutable`, +`derived`, `lifecycle`, `recorded`, `asserted` or `mixed`. -Only the 3 `requestable` fields require a round trip. Everything else the buyer -touches — 20 fields — resolves against bounds the seller declared up front. That -ratio is the design claim: composition is constraint satisfaction, and -negotiation is the exception. +Only the 2 `requestable` fields require a round trip to resolve (§ 5.6.1). +Everything else the buyer touches — 17 fields — resolves against bounds the seller +declared up front. That ratio is the design claim: composition is constraint +satisfaction, and negotiation is the exception.