From ff53dc5a7d436ac42995f9842394369901e9eb70 Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Tue, 6 Oct 2026 16:28:09 +0530 Subject: [PATCH 01/47] Clarify that pricing[].floor is the offer floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit § 5.6 defines floor as the seller's offer floor at proposal time, not the downstream auction floor and not a guarantee that bids will clear. Editorial: the meaning of the field is unchanged. Refs #16 --- spec/CHANGELOG.md | 3 +++ spec/openproposal-3.0.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 1dfacef..5ebc71d 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,6 +5,9 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] +- § 5.6 — `pricing[].floor` is the offer floor, not the downstream auction + floor. Editorial clarification ([#16](https://github.com/IABTechLab/OpenProposal/issues/16)). + ## [3.0-draft-1] — 2026-09-22 Initial draft published for public comment. Ported from the working-group diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 6940958..1a6dc37 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -271,7 +271,7 @@ Each field declares bounds, not a value. A bare value means the field is | `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 | +| `pricing[].floor` | Number | seller-set | OPTIONAL. The seller's offer floor: the lowest rate it will consider at proposal time, so a `requestable` counter can be evaluated without a round trip. Not the downstream auction floor, and not a guarantee that bids at or above it will clear | | `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 | From b70a5c3eda27d08cdf09129c81cbceaf810c139d Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Thu, 22 Oct 2026 15:56:03 +0530 Subject: [PATCH 02/47] Add linked_execution and requires for linked line items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A proposal can say its line items must be bought together (linked_execution, § 4.1), and a line item can name the line items or spend that must be in the same commitment (requires, § 5.6.3). A bonus line is a line item with requires and its own price. The § 2 "binding terms" hook now points at a field, and the Summary Representation carries has_requires and linked_execution. Adds a CTV bonus line example (§ 6.5) and linked_execution: none to the proposal example. Refs #23 --- examples/line-item-ctv-bonus.yaml | 29 ++++++++++++++ examples/proposal.yaml | 1 + spec/CHANGELOG.md | 4 ++ spec/openproposal-3.0.md | 63 +++++++++++++++++++++++++++---- 4 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 examples/line-item-ctv-bonus.yaml diff --git a/examples/line-item-ctv-bonus.yaml b/examples/line-item-ctv-bonus.yaml new file mode 100644 index 0000000..6f3acfc --- /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: { max: { min: 1, max: 2 }, period: "day", basis: household } + +requires: + all_of: ["business-daily-display", "weather-app-ctv"] + any_of: [] + min_commitment: + - { 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 } + +buying_route: SSP_PLATFORM +transaction_mechanism: { available: ["PRIVATE_AUCTION"], included: ["PRIVATE_AUCTION"] } +serving_mode: ssai diff --git a/examples/proposal.yaml b/examples/proposal.yaml index 9b51484..94a6922 100644 --- a/examples/proposal.yaml +++ b/examples/proposal.yaml @@ -5,6 +5,7 @@ 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" diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 1dfacef..074ba67 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,6 +5,10 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] +- § 2, § 4.1, § 5.6 — `linked_execution` on the proposal and `requires` on + line items, for linked execution and bonus line items + ([#23](https://github.com/IABTechLab/OpenProposal/issues/23)). + ## [3.0-draft-1] — 2026-09-22 Initial draft published for public comment. Ported from the working-group diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 6940958..a345e71 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -65,8 +65,9 @@ item. There is no "overridable unless restated" semantics anywhere in this specification. **Subsettable proposals.** A buyer MAY select and commit a subset of a -proposal's line items, unless the proposal explicitly mandates linked execution -through its binding terms. +proposal's line items, unless the proposal sets `linked_execution` to `all` +(§ 4.1). A line item that declares `requires` (§ 5.6.3) may be committed only +when its conditions are met. ### 2.1 Summary and full representations @@ -79,10 +80,13 @@ item: `line_item_id` · `channel` · `content_detail` · property names · `formats.included[]` · `pricing.cost_method` · `pricing.gross_rate` · `min_spend` · `addressable_scale` · `committed_metrics[]` · -`transaction_mechanism` +`transaction_mechanism` · `has_requires` plus, from the parent proposal: `proposal_id` · `seller_name` · `description` · -`valid_from` · `valid_until`. +`valid_from` · `valid_until` · `linked_execution`. + +`has_requires` is `true` when the line item declares `requires` (§ 5.6.3), so a +screening agent does not recommend a line item that cannot be bought on its own. `catalog_ref` values MUST remain unresolved in a summary. @@ -190,6 +194,7 @@ items. | `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 | +| `linked_execution` | Enum | seller-set | `none` (default, subsettable) \| `all` (every published line item must be committed together) | ### 4.2 Agent comprehension @@ -280,6 +285,7 @@ Each field declares bounds, not a value. A bare value means the field is | `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 | +| `requires` | Object | seller-set | OPTIONAL. Conditions that must hold in the same commitment for this line item to be committable. See 5.6.3 | #### 5.6.1 commitment @@ -313,6 +319,41 @@ Transitions: `none` → `requested` → `held` → (`expired` | `released` | `converted`). `converted` is terminal and occurs when the line item reaches `agreed`. +#### 5.6.3 requires + +| Key | Meaning | +| :--- | :--- | +| `all_of[]` | Line items that MUST all be in the same commitment | +| `any_of[]` | Line items of which at least one MUST be in the same commitment | +| `min_commitment[]` | Purchase-level thresholds: `{ line_item_id, basis, amount, currency }` | + +```yaml +requires: + all_of: ["business-daily-display", "weather-app-ctv"] + any_of: [] + min_commitment: + - { 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. +- When `linked_execution` is `all`, a hold MUST use `hold_scope: proposal` + (§ 5.6.2). + ### 5.7 Guarantees | Field | Type | Mutability | Description | @@ -375,6 +416,12 @@ See [`examples/line-item-ctv.yaml`](../examples/line-item-ctv.yaml). 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). + --- ## 7. Why this split @@ -443,6 +490,7 @@ body is normative.** | `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 | +| `linked_execution` | 4.1 Identity and lifecycle | Enum | seller-set | | `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 | @@ -486,6 +534,7 @@ body is normative.** | `cancellation_policy` | 5.6 Commercial terms | Object | selectable | | `exclusivity` | 5.6 Commercial terms | Object | selectable | | `creative_policy` | 5.6 Commercial terms | Object | seller-set | +| `requires` | 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 | @@ -512,7 +561,7 @@ 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[]` | +| seller-set | 31 | Seller sets and may revise while `draft` or `published`. | `valid_from` · `valid_until`, `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 | 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[]` | @@ -522,8 +571,8 @@ body is normative.** ### 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`, +Of 73 fields, **23 are touched by the buyer** — 12 `selectable`, 5 `settable`, 3 +`supplied` and 3 `requestable`. The remaining 50 are `seller-set`, `immutable`, `derived` or `mixed`. Only the 3 `requestable` fields require a round trip. Everything else the buyer From fc7ec37a1419676ccac313b75f0255aef3cc54d1 Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Thu, 22 Oct 2026 15:57:16 +0530 Subject: [PATCH 03/47] Add buyer-supplied block lists to the targeting envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seller declares which exclusion types it accepts and how it enforces each (exclusions_accepted); the buyer supplies its lists (exclusions). Blocked apps carry their store, { platform, bundle }, so one store id is never applied across stores. Exclusions are recompute inputs (§ 3.3) and are never disclosed to other parties (§ 5.8). The display example declares and supplies lists. Refs #25 --- examples/line-item-display.yaml | 8 +++++ spec/CHANGELOG.md | 4 +++ spec/openproposal-3.0.md | 59 ++++++++++++++++++++++++++++----- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/examples/line-item-display.yaml b/examples/line-item-display.yaml index 808af24..7b8368d 100644 --- a/examples/line-item-display.yaml +++ b/examples/line-item-display.yaml @@ -50,6 +50,14 @@ materials_due: "2026-09-24" geo: { allowed: ["US-NY", "US-CA", "US-IL", "US-TX"] } frequency_cap: { max: { min: 1, max: 5 }, period: "day", basis: device } +exclusions_accepted: # what this seller can enforce (§ 5.5) + types: [domain, content_category, keyword] + max_entries: { domain: 5000, keyword: 500 } + enforcement: { domain: pre_serve, content_category: pre_serve, keyword: best_effort } +exclusions: # supplied by the buyer + domain: ["example-gambling.example"] + keyword: ["crypto"] + pricing: - cost_method: CPM gross_rate: 24.00 diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 1dfacef..0bf765f 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,6 +5,10 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] +- § 5.5 — Buyer-supplied block lists: `exclusions_accepted` (what the seller + can enforce) and `exclusions` (the buyer's lists) + ([#25](https://github.com/IABTechLab/OpenProposal/issues/25)). + ## [3.0-draft-1] — 2026-09-22 Initial draft published for public comment. Ported from the working-group diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 6940958..ddb74de 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -163,8 +163,9 @@ treated as `seller-set`. ### 3.3 Re-computation -Because audiences, properties and formats are `selectable`, `addressable_scale` -and `availability` MUST be recomputed against the buyer's actual selection. A +Because audiences, properties and formats are `selectable`, and buyer +`exclusions` (§ 5.5) narrow delivery, `addressable_scale` and `availability` +MUST be recomputed against the buyer's actual selection and exclusions. A single declared figure is correct only for the default composition. --- @@ -251,7 +252,8 @@ channel. ### 5.5 Targeting envelope Each field declares bounds, not a value. A bare value means the field is -`seller-set`. +`seller-set`. `exclusions_accepted` and `exclusions` are the exception: the +seller declares what it can enforce, and the buyer supplies its lists. | Field | Type | Mutability | Description | | :--- | :--- | :--- | :--- | @@ -259,6 +261,42 @@ Each field declares bounds, not a value. A bare value means the field is | `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 | +| `exclusions_accepted` | Object | seller-set | What the seller can enforce: `{ types[], max_entries, enforcement, verification[] }`. See below | +| `exclusions` | Object | supplied | The buyer's block lists: `{ domain[], app_bundle[], content_category, keyword[], list_ref }`. See below | + +**Block lists.** The seller declares which exclusion types it accepts and how it +enforces each; the buyer supplies its lists within that declaration. + +```yaml +exclusions_accepted: + types: [domain, app_bundle, content_category, keyword] + max_entries: { domain: 5000, keyword: 500 } + enforcement: { domain: pre_serve, app_bundle: pre_serve, + content_category: pre_serve, keyword: best_effort } + verification: ["IAS", "DoubleVerify"] + +exclusions: + domain: ["example-gambling.example"] + app_bundle: [{ platform: roku, bundle: "123456" }] + content_category: { taxonomy_ref: "", ids: [""] } + keyword: ["crypto"] + list_ref: { url: "", sha256: "" } +``` + +- Each `app_bundle[]` entry is `{ platform, bundle }`. `bundle` is the OpenRTB + 2.6 `app.bundle` value; `platform` names the store (`ios` \| `android` \| + `roku` \| `fire_tv` \| `samsung_tv` and other store platforms). A seller MUST + reject an entry whose platform it cannot determine, rather than apply it + across stores. +- `content_category` ids come from the taxonomy named in `taxonomy_ref`, which + MUST be version-pinned. +- `enforcement` is `pre_serve` or `best_effort` per type. +- A buyer MUST NOT supply an exclusion type absent from + `exclusions_accepted.types`. A seller MUST reject it deterministically + rather than ignore it. +- A seller MUST NOT present `best_effort` enforcement as a guarantee. +- `list_ref` MUST be hash-pinned, and resolved into the stored record at + `agreed`, as for `catalog_ref` (§ 2.2). ### 5.6 Commercial terms @@ -331,6 +369,9 @@ Transitions: `none` → `requested` → `held` → (`expired` | `released` | | `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 | +`exclusions` (§ 5.5) reveal the buyer's strategy. A seller MUST NOT disclose a +buyer's `exclusions` to any party other than that buyer. + ### 5.9 Execution | Field | Type | Mutability | Description | @@ -470,6 +511,8 @@ body is normative.** | `device` | 5.5 Targeting envelope | Object | settable | | `daypart` | 5.5 Targeting envelope | Object | settable | | `frequency_cap` | 5.5 Targeting envelope | Object | settable | +| `exclusions_accepted` | 5.5 Targeting envelope | Object | seller-set | +| `exclusions` | 5.5 Targeting envelope | Object | supplied | | `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 | @@ -512,21 +555,21 @@ 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[]` | +| seller-set | 30 | 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`, `exclusions_accepted`, `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[]` | +| supplied | 4 | Buyer provides data or an artifact. | `brief_ref`, `buyer_supplied`, `assets[]`, `exclusions` | | 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` | ### 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`, +Of 73 fields, **24 are touched by the buyer** — 12 `selectable`, 5 `settable`, 4 +`supplied` and 3 `requestable`. The remaining 49 are `seller-set`, `immutable`, `derived` 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 +touches — 21 fields — resolves against bounds the seller declared up front. That ratio is the design claim: composition is constraint satisfaction, and negotiation is the exception. From 684c865e6cd20e4165962f2aa044f3fb16e65a2e Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Thu, 22 Oct 2026 15:57:55 +0530 Subject: [PATCH 04/47] Let geo be selectable with per-market price deltas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A line item whose rate differs by market can declare geo with the selectable structure of § 3.1: available[], included[], pricing deltas and max_select. A geo that declares only allowed[] stays settable, so existing payloads are unchanged. When several priced markets are selected, the highest delta applies. The audio example is priced by market. Refs #14 --- examples/line-item-audio.yaml | 6 ++++++ spec/CHANGELOG.md | 3 +++ spec/openproposal-3.0.md | 35 +++++++++++++++++++++++++++-------- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/examples/line-item-audio.yaml b/examples/line-item-audio.yaml index 4ca2f21..37b0d33 100644 --- a/examples/line-item-audio.yaml +++ b/examples/line-item-audio.yaml @@ -13,6 +13,12 @@ production: included: ["buyer_supplied"] pricing: { seller_built: { delta_flat: 12000.00 } } +geo: # priced by market (§ 5.5) + available: ["USA", "CAN", "GBR"] + included: ["USA"] + pricing: { "CAN": { delta_cpm: 1.50 }, "GBR": { delta_cpm: 3.00 } } + max_select: 3 + frequency_cap: { max: { min: 1, max: 6 }, period: "day", basis: listener } pricing: diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 1dfacef..07068b2 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,6 +5,9 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] +- § 5.5 — `geo` may be `selectable`, with per-market price deltas + ([#14](https://github.com/IABTechLab/OpenProposal/issues/14)). + ## [3.0-draft-1] — 2026-09-22 Initial draft published for public comment. Ported from the working-group diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 6940958..ee92c8b 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -163,8 +163,9 @@ treated as `seller-set`. ### 3.3 Re-computation -Because audiences, properties and formats are `selectable`, `addressable_scale` -and `availability` MUST be recomputed against the buyer's actual selection. A +Because audiences, properties, formats and, where it is selectable, `geo` are +`selectable`, `addressable_scale` and `availability` MUST be recomputed against +the buyer's actual selection. A single declared figure is correct only for the default composition. --- @@ -251,15 +252,33 @@ channel. ### 5.5 Targeting envelope Each field declares bounds, not a value. A bare value means the field is -`seller-set`. +`seller-set`. `geo` MAY instead declare the selectable structure of § 3.1, so a +line item can be priced by market. | Field | Type | Mutability | Description | | :--- | :--- | :--- | :--- | -| `geo` | Object | settable | `allowed[]` of geo codes the buyer may select within | +| `geo` | Object | selectable | `available[]` of geo codes with `included[]`, `pricing` deltas and `max_select` (§ 3.1), for a line item priced by market. A `geo` that declares only `allowed[]` is `settable`. See below | | `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 | +**Market-priced lines.** A seller whose rate differs by market MAY declare `geo` +with the selectable structure of § 3.1: + +```yaml +geo: + available: ["SGP", "THA", "IDN", "MYS"] + included: ["SGP", "THA"] + pricing: { "IDN": { delta_cpm: 18.00 }, "MYS": { delta_cpm: 21.00 } } + max_select: 4 +``` + +- A `geo` that declares `allowed[]` and no `available[]` MUST be treated as + `settable` (§ 3.2). A `geo` MUST NOT declare both. +- `pricing` deltas add to `agreed_rate` as for any selectable field (§ 3.1). + When the buyer selects more than one priced option, the highest delta among + them applies. + ### 5.6 Commercial terms | Field | Type | Mutability | Description | @@ -466,7 +485,7 @@ body is normative.** | `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 | +| `geo` | 5.5 Targeting envelope | Object | selectable | | `device` | 5.5 Targeting envelope | Object | settable | | `daypart` | 5.5 Targeting envelope | Object | settable | | `frequency_cap` | 5.5 Targeting envelope | Object | settable | @@ -513,8 +532,8 @@ body is normative.** | :--- | :--- | :--- | :--- | | 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` | +| selectable | 13 | Buyer chooses from a declared set. Resolves without assent. | `properties`, `environments`, `audiences`, `activation_route`, `formats`, `production`, `geo`, `cancellation_policy`, `exclusivity`, `measurement_source`, `shortfall_remedy`, `reporting`, `transaction_mechanism` | +| settable | 4 | Buyer sets a value within declared bounds. Resolves without assent. | `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` | @@ -522,7 +541,7 @@ body is normative.** ### What the distribution says -Of 71 fields, **23 are touched by the buyer** — 12 `selectable`, 5 `settable`, 3 +Of 71 fields, **23 are touched by the buyer** — 13 `selectable`, 4 `settable`, 3 `supplied` and 3 `requestable`. The remaining 48 are `seller-set`, `immutable`, `derived` or `mixed`. From cbe7fe03239a500433c7e0872f1d81095a2659bd Mon Sep 17 00:00:00 2001 From: Kishore2572 Date: Thu, 22 Oct 2026 16:57:53 +0530 Subject: [PATCH 05/47] Require proposal_id and version on commitments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A commitment names the proposal and version it was priced against (§ 5.6.1). The seller rejects a new attempt on a stale version, or past valid_until or price_valid_until, and returns the current version. An authenticated replay with the same idempotency_key and terms returns the stored result without re-running those checks; the same key with different terms is a conflict. Keys are opaque and scoped to one buyer and seller pair. § 4.1 states that each revision while published increments version. The display, CTV and audio examples carry proposal_id and version. Refs #24 --- examples/line-item-audio.yaml | 2 ++ examples/line-item-ctv.yaml | 2 ++ examples/line-item-display.yaml | 2 ++ spec/CHANGELOG.md | 5 +++++ spec/openproposal-3.0.md | 26 ++++++++++++++++++++++---- 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/examples/line-item-audio.yaml b/examples/line-item-audio.yaml index 4ca2f21..28b49c5 100644 --- a/examples/line-item-audio.yaml +++ b/examples/line-item-audio.yaml @@ -21,6 +21,8 @@ pricing: commitment_bounds: { min_budget: 5000.00 } commitment: + proposal_id: "examplepub-us-q4-2026-omni" + version: 1 basis: spend amount: 60000.00 currency: USD diff --git a/examples/line-item-ctv.yaml b/examples/line-item-ctv.yaml index fda3994..7cc3d82 100644 --- a/examples/line-item-ctv.yaml +++ b/examples/line-item-ctv.yaml @@ -17,6 +17,8 @@ pricing: commitment_bounds: { min_budget: 25000.00 } commitment: + proposal_id: "examplepub-us-q4-2026-omni" + version: 1 basis: spend amount: 160000.00 currency: USD diff --git a/examples/line-item-display.yaml b/examples/line-item-display.yaml index 808af24..929a346 100644 --- a/examples/line-item-display.yaml +++ b/examples/line-item-display.yaml @@ -62,6 +62,8 @@ pricing: commitment_bounds: { min_budget: 10000.00, max_budget: 500000.00 } commitment: + proposal_id: "examplepub-us-q4-2026-omni" + version: 1 basis: spend amount: 80000.00 currency: USD diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 1dfacef..1e649d5 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,6 +5,11 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] +- § 4.1, § 5.6.1 — commitments carry `proposal_id` and `version`; a new + attempt on a stale version, or past `valid_until` or `price_valid_until`, is + rejected, and a replay returns the stored result. Breaking + ([#24](https://github.com/IABTechLab/OpenProposal/issues/24)). + ## [3.0-draft-1] — 2026-09-22 Initial draft published for public comment. Ported from the working-group diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 6940958..717c234 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -181,7 +181,7 @@ items. | Field | Type | Mutability | Description | | :--- | :--- | :--- | :--- | | `proposal_id` | String | immutable | Unique identity for the proposal object | -| `version` | Integer | derived | Monotonically increasing revision number | +| `version` | Integer | derived | Monotonically increasing revision number. Each revision while status is `published` increments it | | `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` | @@ -288,6 +288,8 @@ when status becomes `agreed`. | Key | Required | Meaning | | :--- | :--- | :--- | +| `proposal_id` | REQUIRED | The proposal the commitment is made against | +| `version` | REQUIRED | The proposal `version` the commitment was priced against | | `basis` | REQUIRED | `spend` \| `units` | | `amount` | REQUIRED | The figure committed | | `currency` | REQUIRED when `basis = spend` | ISO-4217 | @@ -296,8 +298,22 @@ when status becomes `agreed`. | `derived_units` | derived | Computed from `amount`, `agreed_rate` and `cost_method`. Informative, not binding | | `idempotency_key` | 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. +An `idempotency_key` is opaque and scoped to one buyer and seller pair. The +seller MUST NOT interpret it. + +The seller MUST reject a new commitment attempt whose `version` is not the +current version of the proposal named by `proposal_id`, whose proposal is past +`valid_until`, or whose `pricing[].price_valid_until` has passed. The rejection +MUST return the current version number, so the buyer can fetch it again. These +checks MUST be deterministic, as for `idempotency_key`. + +For an authenticated replay of a commitment attempt the seller has already +processed, with the same `idempotency_key` and the same commitment terms, the +seller MUST return the original result and MUST NOT create a second +commitment. The version and expiry checks apply to a new attempt, not to +returning a stored result. Reusing an `idempotency_key` with different +commitment terms MUST produce an explicit conflict and MUST NOT create or +modify a commitment. #### 5.6.2 hold_status @@ -369,7 +385,9 @@ buyer commitment, and `settable` bounds. See ### 6.3 CTV line item -See [`examples/line-item-ctv.yaml`](../examples/line-item-ctv.yaml). +Shows a buyer commitment naming the `proposal_id` and `version` it was priced +against (§ 5.6.1). See +[`examples/line-item-ctv.yaml`](../examples/line-item-ctv.yaml). ### 6.4 Audio line item From 7a8938579c6339b3c397ab6e40d2b47e1860e195 Mon Sep 17 00:00:00 2001 From: Kishore2572 Date: Thu, 22 Oct 2026 17:04:28 +0530 Subject: [PATCH 06/47] Add proposal_template_id for issued proposals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit § 4.1 adds an optional, immutable proposal_template_id linking an issued proposal to the seller's template. Each issued proposal has its own status, version and negotiation_history[], and buyers never need the template id to transact. § 7 adds a non-normative note separating seller-side templates from the issued proposals buyers commit against. Appendix A gains one row; Appendix B counts immutable 6 → 7 and 72 fields. The proposal example carries a proposal_template_id. Refs #26 --- examples/proposal.yaml | 1 + spec/CHANGELOG.md | 5 +++++ spec/openproposal-3.0.md | 25 ++++++++++++++++++++++--- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/examples/proposal.yaml b/examples/proposal.yaml index 9b51484..66a4954 100644 --- a/examples/proposal.yaml +++ b/examples/proposal.yaml @@ -5,6 +5,7 @@ version: 1 seller_id: "reg:examplepub-digital" seller_name: "Example Publisher US" type: standard +proposal_template_id: "examplepub-q4-omni-template" # seller-side; buyers never resolve it status: published valid_from: "2026-10-01T00:00:00Z" valid_until: "2026-12-31T23:59:59Z" diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 1dfacef..9f9935a 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,6 +5,11 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] +- § 4.1, § 7 — `proposal_template_id` links an issued proposal to the + seller's template; each issued proposal has its own `status`, `version` and + `negotiation_history[]` + ([#26](https://github.com/IABTechLab/OpenProposal/issues/26)). + ## [3.0-draft-1] — 2026-09-22 Initial draft published for public comment. Ported from the working-group diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 6940958..b54de39 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -185,12 +185,18 @@ items. | `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` | +| `proposal_template_id` | String | immutable | OPTIONAL. Links an issued proposal to the seller's template. Opaque to buyers; used for the seller's reporting | | `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 | +- Each issued proposal has an independent `status`, `version` and + `negotiation_history[]`. +- `proposal_template_id` MUST NOT be required for a buyer to transact. Buyers + never resolve it. + ### 4.2 Agent comprehension The case a buying agent reads to decide whether to look further. @@ -396,6 +402,18 @@ See [`examples/line-item-audio.yaml`](../examples/line-item-audio.yaml). satisfaction rather than negotiation. Only genuinely out-of-envelope asks require the counterparty to respond. +**Templates and issued proposals (non-normative).** + +- **Template:** seller-side only. It lives in the seller's own systems (its + proposal store), is not part of the public object, and is never transacted + directly. +- **Issued proposal:** what buyers see and commit against. Each has its own + `proposal_id` per counterparty or destination, and may carry + `proposal_template_id` (§ 4.1) to trace it back to its template. +- Buyer-specific terms, such as annual rates and named visibility, live on the + issued proposal's line items: `visibility: named`, `eligible_bidders[]` and + their own `pricing[]`. + --- ## 8. Open questions @@ -438,6 +456,7 @@ body is normative.** | `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 | +| `proposal_template_id` | 4.1 Identity and lifecycle | String | 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 | @@ -511,7 +530,7 @@ 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` | +| immutable | 7 | Set once at creation. Neither party may change it. | `proposal_id`, `seller_id`, `seller_name`, `type`, `proposal_template_id`, `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` | @@ -522,8 +541,8 @@ body is normative.** ### 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`, +Of 72 fields, **23 are touched by the buyer** — 12 `selectable`, 5 `settable`, 3 +`supplied` and 3 `requestable`. The remaining 49 are `seller-set`, `immutable`, `derived` or `mixed`. Only the 3 `requestable` fields require a round trip. Everything else the buyer From af6c878d82694af8b86c80c972bc09509c0ae11e Mon Sep 17 00:00:00 2001 From: Kishore2572 Date: Thu, 22 Oct 2026 17:08:39 +0530 Subject: [PATCH 07/47] Add terms_ref and agreement_ref for contract references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit § 5.8 adds terms_ref ({ url, sha256, version }), REQUIRED when terms_acceptance_required is true, hash-pinned and frozen at agreed, so assent binds to an exact document. § 5.10 adds agreement_ref, an informative pointer to the master or annual agreement a line item is priced under, and documents the io and agreement roles on external_references[]. IO generation is stated to be a connector concern, and § 7 adds a non-normative note on relationships. agreement_ref is seller-set; a buyer-held agreement id travels as an external_references[] entry with role: agreement, so the field keeps one marker (§ 3). Appendix A gains two rows; Appendix B counts seller-set 29 → 31 and 73 fields. The display example carries terms_ref. Refs #27 --- examples/line-item-display.yaml | 3 +++ spec/CHANGELOG.md | 5 +++++ spec/openproposal-3.0.md | 26 ++++++++++++++++++++++---- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/examples/line-item-display.yaml b/examples/line-item-display.yaml index 808af24..e515e05 100644 --- a/examples/line-item-display.yaml +++ b/examples/line-item-display.yaml @@ -89,6 +89,9 @@ reporting: visibility: registry_gated required_credentials: [{ type: dsp_seat, scheme: jwt_keypair, issuer: "dsp-a", id_required: true }] +terms_acceptance_required: true +terms_ref: { url: "https://examplepub.example/legal/io-terms-2026.pdf", + sha256: "", version: "2026.1" } buying_route: PUBLISHER_AD_SERVER transaction_mechanism: { available: ["PROGRAMMATIC_GUARANTEED", "PREFERRED_DEAL"], diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 1dfacef..29f01b3 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,6 +5,11 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] +- § 5.8, § 5.10, § 7 — `terms_ref` for the exact terms accepted at assent, + `agreement_ref` for the agreement a line item is priced under, and `io` and + `agreement` roles on `external_references[]`. Breaking + ([#27](https://github.com/IABTechLab/OpenProposal/issues/27)). + ## [3.0-draft-1] — 2026-09-22 Initial draft published for public comment. Ported from the working-group diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 6940958..62e6a19 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -330,6 +330,9 @@ Transitions: `none` → `requested` → `held` → (`expired` | `released` | | `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 | +| `terms_ref` | Object | seller-set | OPTIONAL; REQUIRED when `terms_acceptance_required` is `true`. `{ url, sha256, version }`: the legal terms or IO template the buyer accepts at assent. Hash-pinned, so assent (§ 4.1) binds to an exact document | + +`terms_ref` MUST be hash-pinned and frozen at `agreed`. ### 5.9 Execution @@ -346,11 +349,16 @@ Transitions: `none` → `requested` → `held` → (`expired` | `released` | | 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 | +| `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. Documented `role` values: `io`, an IO id generated by an order management system and carried back on the line item; `agreement`, a buyer-held agreement id | +| `agreement_ref` | Object | seller-set | OPTIONAL. Reference to a master or annual agreement the line item is priced under: `{ agreement_id, parties: [seller_id, buyer_id], type: upfront \| annual \| master_services \| other, valid_from, valid_until }`. An informative pointer; the agreement itself is outside this specification. A buyer-held agreement id travels as an `external_references[]` entry with `role: agreement` | | `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 | +This specification does not define IO generation. IO creation is a connector +concern. OpenProposal supplies the agreed record, `terms_ref` and +`correlation_id` a connector needs. + --- ## 6. Examples @@ -396,6 +404,14 @@ See [`examples/line-item-audio.yaml`](../examples/line-item-audio.yaml). satisfaction rather than negotiation. Only genuinely out-of-envelope asks require the counterparty to respond. +**Relationships, agreements and IOs (non-normative).** Buyer–seller +relationships are expressed as issued proposals (`visibility: named`, +`eligible_bidders[]` set to that buyer, with the negotiated `pricing[]`) plus +the buyer agent's business rules. "Preferred partner" weighting is a buyer-side +ranking rule, not part of this specification. `agreement_ref` and `terms_ref` +(§ 5.10, § 5.8) point at the agreement and terms a line item is executed under; +IO generation happens through connectors. + --- ## 8. Open questions @@ -494,6 +510,7 @@ body is normative.** | `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 | +| `terms_ref` | 5.8 Transaction eligibility | Object | 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 | @@ -501,6 +518,7 @@ body is normative.** | `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 | +| `agreement_ref` | 5.10 External references and settlement | Object | seller-set | | `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 | @@ -512,7 +530,7 @@ 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[]` | +| seller-set | 31 | 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`, `terms_ref`, `buying_route`, `serving_mode`, `serving_platform`, `agreement_ref`, `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[]` | @@ -522,8 +540,8 @@ body is normative.** ### 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`, +Of 73 fields, **23 are touched by the buyer** — 12 `selectable`, 5 `settable`, 3 +`supplied` and 3 `requestable`. The remaining 50 are `seller-set`, `immutable`, `derived` or `mixed`. Only the 3 `requestable` fields require a round trip. Everything else the buyer From 8e1f614a5c0546e88d993ed239ffcdc71b395a78 Mon Sep 17 00:00:00 2001 From: Kishore2572 Date: Thu, 22 Oct 2026 17:16:07 +0530 Subject: [PATCH 08/47] Add a static publication manifest at a well-known URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new § 2.3 lets a seller publish its public standard line items at /.well-known/openproposal.json: seller_id, generated_at, spec_version and, per proposal, proposal_id, version, the Summary Representation of each line item and a full_url. ETag / Last-Modified are required, only line items with visibility public appear, and entries must match the current version. Buyers discover from the manifest, then fetch the Full Representation of shortlisted line items. Adds examples/manifest.json (§ 6.5) and visibility: public to the CTV and audio examples; the registry_gated display line item is not listed. Refs #29 --- examples/line-item-audio.yaml | 1 + examples/line-item-ctv.yaml | 1 + examples/manifest.json | 67 +++++++++++++++++++++++++++++++++++ spec/CHANGELOG.md | 5 +++ spec/openproposal-3.0.md | 31 ++++++++++++++++ 5 files changed, 105 insertions(+) create mode 100644 examples/manifest.json diff --git a/examples/line-item-audio.yaml b/examples/line-item-audio.yaml index 4ca2f21..245e297 100644 --- a/examples/line-item-audio.yaml +++ b/examples/line-item-audio.yaml @@ -29,6 +29,7 @@ commitment: 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"] } serving_mode: dai diff --git a/examples/line-item-ctv.yaml b/examples/line-item-ctv.yaml index fda3994..d2eded9 100644 --- a/examples/line-item-ctv.yaml +++ b/examples/line-item-ctv.yaml @@ -29,6 +29,7 @@ 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"] } serving_mode: ssai diff --git a/examples/manifest.json b/examples/manifest.json new file mode 100644 index 0000000..7652e1f --- /dev/null +++ b/examples/manifest.json @@ -0,0 +1,67 @@ +{ + "seller_id": "reg:examplepub-digital", + "generated_at": "2026-10-01T06: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.", + "valid_from": "2026-10-01T00:00:00Z", + "valid_until": "2026-12-31T23:59:59Z", + "full_url": "https://examplepub.example/openproposal/examplepub-us-q4-2026-omni", + "line_items": [ + { + "line_item_id": "weather-app-ctv", + "channel": "ctv", + "properties": [ + "weather-app" + ], + "formats": { + "included": [ + "ctv/instream-15s" + ] + }, + "pricing": { + "cost_method": "CPM", + "gross_rate": 45.0 + }, + "committed_metrics": [ + { + "metric": "completion_rate", + "value": 0.95, + "basis": "seller_reported" + } + ], + "transaction_mechanism": [ + "PRIVATE_AUCTION" + ] + }, + { + "line_item_id": "streaming-audio", + "channel": "audio", + "formats": { + "included": [ + "audio/produced-30s" + ] + }, + "pricing": { + "cost_method": "CPM", + "gross_rate": 18.0 + }, + "committed_metrics": [ + { + "metric": "completion_rate", + "value": 0.92, + "basis": "seller_reported" + } + ], + "transaction_mechanism": [ + "PREFERRED_DEAL" + ] + } + ] + } + ] +} diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 1dfacef..a538687 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,6 +5,11 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] +- § 2.3 — a seller MAY publish its public standard line items as a static + manifest at `/.well-known/openproposal.json`, for a two-phase + discover-then-propose flow + ([#29](https://github.com/IABTechLab/OpenProposal/issues/29)). + ## [3.0-draft-1] — 2026-09-22 Initial draft published for public comment. Ported from the working-group diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 6940958..100b5dc 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -110,6 +110,31 @@ example: "examplepub/audiences/smb-decision-makers@3" by its resolved value in the stored record, so the committed artifact is self-contained and immutable. +### 2.3 Publication + +A seller MAY publish `https:///.well-known/openproposal.json`. + +Contents: `{ seller_id, generated_at, spec_version, proposals[] }`, where each +entry in `proposals[]` carries `proposal_id`, `version`, the Summary +Representation (§ 2.1) of each line item, and a `full_url` for the second +phase. + +- `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. +- Property lists stay as names in the summary; large lists use `catalog_ref` + (§ 2.2). + +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. + --- ## 3. Field mutability @@ -375,6 +400,12 @@ See [`examples/line-item-ctv.yaml`](../examples/line-item-ctv.yaml). See [`examples/line-item-audio.yaml`](../examples/line-item-audio.yaml). +### 6.5 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 From 74a25448baf9e38048a5bd417b4372d46faaa77f Mon Sep 17 00:00:00 2001 From: Kishore2572 Date: Thu, 22 Oct 2026 20:16:47 +0530 Subject: [PATCH 09/47] Scope the replay rule to the same buyer The specification does not define authentication yet (WS1; see #62), so the replay rule now applies to a replay from the same buyer rather than an authenticated replay. Refs #24 --- spec/openproposal-3.0.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 717c234..62d37d5 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -307,9 +307,9 @@ current version of the proposal named by `proposal_id`, whose proposal is past MUST return the current version number, so the buyer can fetch it again. These checks MUST be deterministic, as for `idempotency_key`. -For an authenticated replay of a commitment attempt the seller has already -processed, with the same `idempotency_key` and the same commitment terms, the -seller MUST return the original result and MUST NOT create a second +For a replay from the same buyer of a commitment attempt the seller has +already processed, with the same `idempotency_key` and the same commitment +terms, the seller MUST return the original result and MUST NOT create a second commitment. The version and expiry checks apply to a new attempt, not to returning a stored result. Reusing an `idempotency_key` with different commitment terms MUST produce an explicit conflict and MUST NOT create or From b88e9a0719180303917efdf59d20ba1769172d27 Mon Sep 17 00:00:00 2001 From: Kishore2572 Date: Thu, 22 Oct 2026 20:17:11 +0530 Subject: [PATCH 10/47] Name agreement_ref parties with defined identifiers agreement_ref.parties used buyer_id, which the specification does not define. parties[] now names the seller by its seller_id and the buyer by the id it holds in eligible_bidders[] (section 5.8). Refs #27 --- spec/openproposal-3.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 62e6a19..a315fd1 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -350,7 +350,7 @@ Transitions: `none` → `requested` → `held` → (`expired` | `released` | | 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. Documented `role` values: `io`, an IO id generated by an order management system and carried back on the line item; `agreement`, a buyer-held agreement id | -| `agreement_ref` | Object | seller-set | OPTIONAL. Reference to a master or annual agreement the line item is priced under: `{ agreement_id, parties: [seller_id, buyer_id], type: upfront \| annual \| master_services \| other, valid_from, valid_until }`. An informative pointer; the agreement itself is outside this specification. A buyer-held agreement id travels as an `external_references[]` entry with `role: agreement` | +| `agreement_ref` | Object | seller-set | OPTIONAL. Reference to a master or annual agreement the line item is priced under: `{ agreement_id, parties[], type: upfront \| annual \| master_services \| other, valid_from, valid_until }`, where `parties[]` names the seller by its `seller_id` and the buyer by the id it holds in `eligible_bidders[]` (§ 5.8). An informative pointer; the agreement itself is outside this specification. A buyer-held agreement id travels as an `external_references[]` entry with `role: agreement` | | `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 | From ecec3d74f484dbb4ea4404fb7fe847cbbd94aafe Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Fri, 23 Oct 2026 18:50:47 +0530 Subject: [PATCH 11/47] Keep the hold rule out of the spec text The rule that a hold on a linked_execution: all proposal uses hold_scope: proposal came from a comment on #23, not the change request as filed. It moves to the PR description as a point for the working group, so this PR carries only the filed change. Refs #23 --- spec/openproposal-3.0.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index a345e71..7de3385 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -351,8 +351,6 @@ self-contained. dependent line item's `cancellation_policy`. - A seller MUST reject a proposal at publication if `requires` references form a cycle. -- When `linked_execution` is `all`, a hold MUST use `hold_scope: proposal` - (§ 5.6.2). ### 5.7 Guarantees From 63712c0683b1f67e032450f7400979be60b5cde4 Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Fri, 23 Oct 2026 18:51:43 +0530 Subject: [PATCH 12/47] Restore iab_content_category as filed #25 names the content-category list iab_content_category[], a list of IAB Content Taxonomy ids. The earlier commit renamed it content_category with a version-pinned taxonomy_ref, which nobody proposed. This restores the filed name and shape; which taxonomy version to reference is left to the working group. Refs #25 --- examples/line-item-display.yaml | 4 ++-- spec/openproposal-3.0.md | 11 +++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/examples/line-item-display.yaml b/examples/line-item-display.yaml index 7b8368d..984c32d 100644 --- a/examples/line-item-display.yaml +++ b/examples/line-item-display.yaml @@ -51,9 +51,9 @@ geo: { allowed: ["US-NY", "US-CA", "US-IL", "US-TX"] } frequency_cap: { max: { min: 1, max: 5 }, period: "day", basis: device } exclusions_accepted: # what this seller can enforce (§ 5.5) - types: [domain, content_category, keyword] + types: [domain, iab_content_category, keyword] max_entries: { domain: 5000, keyword: 500 } - enforcement: { domain: pre_serve, content_category: pre_serve, keyword: best_effort } + enforcement: { domain: pre_serve, iab_content_category: pre_serve, keyword: best_effort } exclusions: # supplied by the buyer domain: ["example-gambling.example"] keyword: ["crypto"] diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index ddb74de..4a802d4 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -262,23 +262,23 @@ seller declares what it can enforce, and the buyer supplies its lists. | `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 | | `exclusions_accepted` | Object | seller-set | What the seller can enforce: `{ types[], max_entries, enforcement, verification[] }`. See below | -| `exclusions` | Object | supplied | The buyer's block lists: `{ domain[], app_bundle[], content_category, keyword[], list_ref }`. See below | +| `exclusions` | Object | supplied | The buyer's block lists: `{ domain[], app_bundle[], iab_content_category[], keyword[], list_ref }`. See below | **Block lists.** The seller declares which exclusion types it accepts and how it enforces each; the buyer supplies its lists within that declaration. ```yaml exclusions_accepted: - types: [domain, app_bundle, content_category, keyword] + types: [domain, app_bundle, iab_content_category, keyword] max_entries: { domain: 5000, keyword: 500 } enforcement: { domain: pre_serve, app_bundle: pre_serve, - content_category: pre_serve, keyword: best_effort } + iab_content_category: pre_serve, keyword: best_effort } verification: ["IAS", "DoubleVerify"] exclusions: domain: ["example-gambling.example"] app_bundle: [{ platform: roku, bundle: "123456" }] - content_category: { taxonomy_ref: "", ids: [""] } + iab_content_category: ["IAB-CT3.0:"] # IAB Content Taxonomy ids keyword: ["crypto"] list_ref: { url: "", sha256: "" } ``` @@ -288,8 +288,7 @@ exclusions: `roku` \| `fire_tv` \| `samsung_tv` and other store platforms). A seller MUST reject an entry whose platform it cannot determine, rather than apply it across stores. -- `content_category` ids come from the taxonomy named in `taxonomy_ref`, which - MUST be version-pinned. +- `iab_content_category[]` entries are IAB Content Taxonomy ids. - `enforcement` is `pre_serve` or `best_effort` per type. - A buyer MUST NOT supply an exclusion type absent from `exclusions_accepted.types`. A seller MUST reject it deterministically From 208f0c2c2aa1fe7551b2adbcc2489ed649ca70b7 Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Fri, 23 Oct 2026 18:52:02 +0530 Subject: [PATCH 13/47] Leave combining several market deltas to the working group #14 doesn't say how deltas combine when a buyer selects more than one priced market, and neither did any comment on it. The earlier commit said the highest delta applies; that is a design choice nobody proposed, so it moves to the PR description as a question. Refs #14 --- spec/openproposal-3.0.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index ee92c8b..debf761 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -276,8 +276,6 @@ geo: - A `geo` that declares `allowed[]` and no `available[]` MUST be treated as `settable` (§ 3.2). A `geo` MUST NOT declare both. - `pricing` deltas add to `agreed_rate` as for any selectable field (§ 3.1). - When the buyer selects more than one priced option, the highest delta among - them applies. ### 5.6 Commercial terms From 6573e9c2ee203eb3f45153a48c6286c8a3ea8914 Mon Sep 17 00:00:00 2001 From: Aleksander Sekowski Date: Sat, 24 Oct 2026 10:46:40 -0700 Subject: [PATCH 14/47] Add discussion fixtures for pricing alternatives and seller fees --- .github/workflows/validate.yml | 10 + .../pricing-commitment-arithmetic.md | 158 ++ fixtures/pricing/cases.json | 1450 +++++++++++++++++ scripts/check_pricing_fixtures.py | 166 ++ 4 files changed, 1784 insertions(+) create mode 100644 docs/proposals/pricing-commitment-arithmetic.md create mode 100644 fixtures/pricing/cases.json create mode 100644 scripts/check_pricing_fixtures.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1ea9da9..7ca1a1c 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -67,3 +67,13 @@ jobs: python-version: "3.12" - name: Check every field in the body appears in Appendix A run: python3 scripts/check_appendix.py + + pricing-fixtures: + name: Pricing discussion fixtures + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python3 scripts/check_pricing_fixtures.py diff --git a/docs/proposals/pricing-commitment-arithmetic.md b/docs/proposals/pricing-commitment-arithmetic.md new file mode 100644 index 0000000..db0aa6a --- /dev/null +++ b/docs/proposals/pricing-commitment-arithmetic.md @@ -0,0 +1,158 @@ +# Resolve one price and account for seller fees once + +Implementation feedback for [#30](https://github.com/IABTechLab/OpenProposal/issues/30), +[#94](https://github.com/IABTechLab/OpenProposal/issues/94) and +[#115](https://github.com/IABTechLab/OpenProposal/issues/115), against draft +`44dfaaf618736219426c311ec997b6d37e94b950`. + +This is a proposed calculation profile with executable discussion vectors. +It is not an adopted requirement, an OpenProposal schema, or evidence of a +defect in a deployed implementation. Its fixture keys are an adapter format. + +## The proposals currently produce incompatible results + +#94 makes `pricing[]` entries alternatives and selects exactly one. +#115 puts seller-billed fees into additional `pricing[]` entries and includes +them in `commitment.amount`. Treating a fee as another alternative does not +add it to the selected price. Summing all rows instead loses #94's choice of +cost method, currency and transaction mechanism. + +There is also an arithmetic consequence. At USD 25 CPM, a USD 50,000 spend +commitment containing a USD 2,500 production fee leaves USD 47,500 for media: + +``` +media_spend = 50,000 - 2,500 = 47,500 USD +derived_units = 47,500 / 25 * 1,000 = 1,900,000 impressions +``` + +Computing units from the whole commitment gives 2,000,000 impressions. Those +units cost USD 50,000 before the fee, for a seller total of USD 52,500. +Therefore #115's statement that `derived_units` is unaffected needs revision +if `amount` includes charges not included in the unit rate. + +## Proposed wording for joint resolution + +This wording assumes adoption of the alternative-selection part of #94. +It supplies the missing fee basis in #115 and clarifies the counter described +in #30. The working group can decide these pieces together without adopting +the entire cost-method vocabulary. + +### Sections 3.1 and 5.6: select the price and compose the rate + +`pricing[]` entries are alternative prices, not additive charges. A commitment +MUST resolve to exactly one entry under the selection rule in #94. Only that +entry determines the cost method, rate, floor and currency. An implementation +MUST NOT sum alternative entries to compute the buyer's charge. + +A selected option's `delta_flat` is a one-time seller charge for that line +item commitment. It MUST NOT enter the unit rate or be represented as another +alternative price solely to make it additive. A cost already included in the +unit rate MUST NOT also be added as a flat charge. Each selected option that +is not already included contributes its charge once. + +Unless a final-rate counter has been accepted, the composed rate is the chosen +entry's `gross_rate`, plus applicable `delta_cpm` values, then multiplied by +each applicable `(1 + delta_pct)` factor. For this proposal, `delta_pct` is a +decimal fraction: `0.20` means a 20 percent increase. `delta_cpm` applies only +to CPM and CPMV. Flat charges are outside this calculation. + +An accepted counter stated as the final composed rate MUST become +`agreed_rate` without reapplying the option rate adjustments. It MUST be bound +to the proposal revision, chosen price and resolved selections to which the +seller assented. A changed selection or revision requires a new applicable +acceptance. This rule does not prescribe an acceptance protocol. The floor +comparison uses that same final composed rate. A final-rate counter does not +waive separately identified flat charges unless the accepted terms explicitly +change those charges. + +### Section 5.6.1: define the spend basis + +For `basis = spend`, `amount` is the total pre-tax amount payable to the seller +for this line item, including applicable seller flat charges. It excludes +amounts payable by the buyer to other parties. When the selected cost method +has a unit rate, the amount available for those units is: + +``` +flat_charges = sum of applicable one-time seller charges +media_spend = commitment.amount - flat_charges +derived_units = media_spend / agreed_rate * rate_per +``` + +`rate_per` is 1,000 for CPM and CPMV, and 1 for CPC. The agreed rate MUST be +positive. Flat charges MUST NOT exceed the spend commitment. Zero remaining +media spend yields zero informative units and still has to satisfy any +applicable minimum-unit requirement. + +For `basis = units`, the committed units remain binding and the corresponding +seller total is: + +``` +media_spend = commitment.amount / rate_per * agreed_rate +seller_total = media_spend + flat_charges +``` + +`min_spend` and budget bounds apply to the seller total on the same pre-tax +basis. Impression bounds apply only when the quantity is actually measured +in impressions; clicks or viewable impressions MUST NOT silently be treated +as the same quantity. The selected entry's currency applies to the rate, +flat charges and budget bounds. No currency conversion is implied. + +The calculations above do not round informative quantities. A displayed +rounded estimate MUST NOT change the committed amount or imply additional +authorized spend. Invoice rounding and minor-unit settlement need a separate +rule if they are to be interoperable. + +### Deliberate limits of this first proposal + +- This profile addresses seller fees already expressible through selected + `delta_flat` options. It does not invent a general fee object. If #115 needs + mandatory charges that cannot use that structure, their representation + needs a separate decision. They cannot be silently omitted. +- The proposed `billing.tax_inclusive` branch of #115 needs a tax amount or a + deterministic tax calculation before its units can be derived. The runner + rejects that branch rather than assuming tax is zero. +- #103's print discounts and surcharges introduce separate calculation bases. + Its `value_pct` and conditions are not interpreted by this profile. +- Cost methods other than CPM, CPMV and CPC are not implemented here. An + unsupported profile case is not a declaration that an OpenProposal is + invalid. Extension definitions and transaction-mechanism eligibility stay + with #94. +- A passing calculation establishes arithmetic consistency only. It does + not establish seller acceptance, inventory reservation or an agreed order. + +## Worked counter example + +A base CPM of USD 30 plus USD 4, USD 1.50 and USD 3 option adjustments gives +USD 38.50. If the seller accepts USD 30 as the final rate for those selections, +the rate is USD 30. Applying the adjustments again incorrectly restores +USD 38.50. A separate USD 2,500 flat fee remains outside that rate: one million +committed impressions cost USD 32,500 in total. + +## Reproduce the vectors + +``` +python3 scripts/check_pricing_fixtures.py +python3 scripts/check_pricing_fixtures.py --json +``` + +The corpus is [`fixtures/pricing/cases.json`](../../fixtures/pricing/cases.json). +Expected outcomes are fixed independently of the calculator. Cases cover +alternative selection, fee-inclusive spend, unit commitments, counters, +currency, floors, bounds, percentage composition and unsupported semantics. + +Adapter inputs contain only the already resolved selected adjustments that +are not included in the base price. They do not extract selections from a +proposal or implement #22. Adjustment IDs are field-qualified to avoid +counting the same resolved adjustment twice. The caller has already confirmed +mechanism eligibility and seller acceptance of any `accepted_counter`. +`selection_key` is an opaque test binding, not an OpenProposal field or an +implementation of revision authentication. + +Decimal strings and exact rational outputs avoid hidden binary floating point +rounding. For example, `"1000/3"` means the exact informative quantity one +thousand divided by three. This is a fixture convention, not a proposed JSON +wire type. Currency checks cover agreement and shape, not ISO-4217 membership. + +No normative body or field index is changed by this discussion corpus. If +the wording is adopted, update sections 3.1, 5.6 and 5.6.1, the affected field +descriptions in Appendices A and B, and the committed examples together. diff --git a/fixtures/pricing/cases.json b/fixtures/pricing/cases.json new file mode 100644 index 0000000..0c65d9e --- /dev/null +++ b/fixtures/pricing/cases.json @@ -0,0 +1,1450 @@ +{ + "profile": "pricing-discussion-1", + "cases": [ + { + "id": "cpm-no-fees", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "units": "2000000" + } + } + }, + { + "id": "seller-fee-inside-spend", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "2500", + "media_spend": "47500", + "seller_total": "50000", + "units": "1900000" + } + } + }, + { + "id": "units-add-fee-to-total", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "2000000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "2500", + "media_spend": "50000", + "seller_total": "52500", + "units": "2000000" + } + } + }, + { + "id": "units-total-exceeds-budget", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "2000000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ], + "bounds": { + "max_budget": "50000" + } + }, + "expected": { + "error": "outside_max_budget" + } + }, + { + "id": "spend-fee-does-not-increase-total", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ], + "bounds": { + "max_budget": "50000", + "min_budget": "50000", + "min_spend": "50000" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "2500", + "media_spend": "47500", + "seller_total": "50000", + "units": "1900000" + } + } + }, + { + "id": "fees-exceed-spend", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "2499", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "error": "fees_exceed_spend" + } + }, + { + "id": "fees-equal-spend-zero-media", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "2500", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "2500", + "media_spend": "0", + "seller_total": "2500", + "units": "0" + } + } + }, + { + "id": "two-fees-count-once", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + }, + { + "id": "data/package", + "delta_flat": "500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "3000", + "media_spend": "47000", + "seller_total": "50000", + "units": "1880000" + } + } + }, + { + "id": "duplicate-adjustment-rejected", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + }, + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "error": "duplicate_adjustment" + } + }, + { + "id": "flat-credit-out-of-scope", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "rebate", + "delta_flat": "-1" + } + ] + }, + "expected": { + "error": "unsupported_flat_credit" + } + }, + { + "id": "three-rate-deltas", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "30", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "38500", + "currency": "USD", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "77/2", + "flat_charges": "0", + "media_spend": "38500", + "seller_total": "38500", + "units": "1000000" + } + } + }, + { + "id": "accepted-final-counter-no-double-add", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "30", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "30000", + "currency": "USD", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + } + ], + "selection_key": "revision-7/cpm-usd/options-abc", + "accepted_counter": { + "rate": "30", + "selection_key": "revision-7/cpm-usd/options-abc" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "30", + "flat_charges": "0", + "media_spend": "30000", + "seller_total": "30000", + "units": "1000000" + } + } + }, + { + "id": "counter-keeps-flat-fees", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "30", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "32500", + "currency": "USD", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + }, + { + "id": "production/build", + "delta_flat": "2500" + } + ], + "selection_key": "revision-7/cpm-usd/options-abc", + "accepted_counter": { + "rate": "30", + "selection_key": "revision-7/cpm-usd/options-abc" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "30", + "flat_charges": "2500", + "media_spend": "30000", + "seller_total": "32500", + "units": "1000000" + } + } + }, + { + "id": "counter-on-changed-selection", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "30", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "30000", + "currency": "USD", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + } + ], + "selection_key": "revision-7/cpm-usd/options-abcd", + "accepted_counter": { + "rate": "30", + "selection_key": "revision-7/cpm-usd/options-abc" + } + }, + "expected": { + "error": "stale_counter" + } + }, + { + "id": "counter-without-binding", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [], + "accepted_counter": { + "rate": "30", + "selection_key": "revision-7/cpm-usd/options-abc" + } + }, + "expected": { + "error": "counter_binding_required" + } + }, + { + "id": "counter-below-final-floor", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "30", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "30000", + "currency": "USD", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + } + ], + "selection_key": "revision-7/cpm-usd/options-abc", + "accepted_counter": { + "rate": "19", + "selection_key": "revision-7/cpm-usd/options-abc" + } + }, + "expected": { + "error": "below_floor" + } + }, + { + "id": "floor-equality-allowed", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "30", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "30000", + "currency": "USD", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + } + ], + "selection_key": "revision-7/cpm-usd/options-abc", + "accepted_counter": { + "rate": "20", + "selection_key": "revision-7/cpm-usd/options-abc" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "20", + "flat_charges": "0", + "media_spend": "30000", + "seller_total": "30000", + "units": "1500000" + } + } + }, + { + "id": "percent-after-cpm-delta", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "36000", + "currency": "USD", + "adjustments": [ + { + "id": "format", + "delta_cpm": "5" + }, + { + "id": "rights", + "delta_pct": "0.20" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "36", + "flat_charges": "0", + "media_spend": "36000", + "seller_total": "36000", + "units": "1000000" + } + } + }, + { + "id": "percentage-compounds-and-excludes-flat", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "29500", + "currency": "USD", + "adjustments": [ + { + "id": "rights", + "delta_pct": "0.20" + }, + { + "id": "discount", + "delta_pct": "-0.10" + }, + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "27", + "flat_charges": "2500", + "media_spend": "27000", + "seller_total": "29500", + "units": "1000000" + } + } + }, + { + "id": "zero-percentage-factor", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "discount", + "delta_pct": "-1" + } + ] + }, + "expected": { + "error": "nonpositive_percentage_factor" + } + }, + { + "id": "percent-not-reapplied-to-counter", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "30", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "30000", + "currency": "USD", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + }, + { + "id": "rights", + "delta_pct": "0.20" + } + ], + "selection_key": "revision-7/cpm-usd/options-abc", + "accepted_counter": { + "rate": "30", + "selection_key": "revision-7/cpm-usd/options-abc" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "30", + "flat_charges": "0", + "media_spend": "30000", + "seller_total": "30000", + "units": "1000000" + } + } + }, + { + "id": "alternatives-require-choice", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + }, + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "gross_rate": "2", + "currency": "USD" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "pricing_choice_required" + } + }, + { + "id": "choose-cpc-without-summing-cpm", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + }, + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "gross_rate": "2", + "currency": "USD" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ], + "pricing_id": "cpc-usd" + }, + "expected": { + "result": { + "pricing_id": "cpc-usd", + "currency": "USD", + "agreed_rate": "2", + "flat_charges": "2500", + "media_spend": "47500", + "seller_total": "50000", + "units": "23750" + } + } + }, + { + "id": "choose-cpm-without-summing-cpc", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + }, + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "gross_rate": "2", + "currency": "USD" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [], + "pricing_id": "cpm-usd" + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "units": "2000000" + } + } + }, + { + "id": "cpc-rejects-cpm-delta", + "input": { + "pricing": [ + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "gross_rate": "2", + "currency": "USD" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + } + ] + }, + "expected": { + "error": "incompatible_delta_cpm" + } + }, + { + "id": "cpc-does-not-treat-clicks-as-impressions", + "input": { + "pricing": [ + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "gross_rate": "2", + "currency": "USD" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [], + "bounds": { + "min_impressions": "1" + } + }, + "expected": { + "error": "unsupported_impression_bound" + } + }, + { + "id": "duplicate-pricing-id", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + }, + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [], + "pricing_id": "cpm-usd" + }, + "expected": { + "error": "duplicate_pricing_id" + } + }, + { + "id": "unknown-pricing-id", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [], + "pricing_id": "absent" + }, + "expected": { + "error": "unknown_pricing_id" + } + }, + { + "id": "currency-mismatch", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "EUR", + "adjustments": [] + }, + "expected": { + "error": "currency_mismatch" + } + }, + { + "id": "select-eur-alternative", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + }, + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "gross_rate": "2", + "currency": "USD" + }, + { + "pricing_id": "cpm-eur", + "cost_method": "cpm", + "gross_rate": "20", + "currency": "EUR" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "EUR", + "adjustments": [], + "pricing_id": "cpm-eur" + }, + "expected": { + "result": { + "pricing_id": "cpm-eur", + "currency": "EUR", + "agreed_rate": "20", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "units": "2500000" + } + } + }, + { + "id": "zero-rate", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "0", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "nonpositive_rate" + } + }, + { + "id": "negative-rate", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "-1", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "nonpositive_rate" + } + }, + { + "id": "zero-spend", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "0", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "nonpositive_amount" + } + }, + { + "id": "negative-units", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "-1", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "nonpositive_amount" + } + }, + { + "id": "float-not-fixture-decimal", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": 50000.0, + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "invalid_number" + } + }, + { + "id": "nonfinite-number", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "NaN", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "invalid_number" + } + }, + { + "id": "exact-decimal-no-binary-rounding", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "0.1", + "currency": "USD", + "floor": "0" + } + ], + "basis": "spend", + "amount": "0.3", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "1/10", + "flat_charges": "0", + "media_spend": "3/10", + "seller_total": "3/10", + "units": "3000" + } + } + }, + { + "id": "fractional-units-not-rounded-up", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "3", + "currency": "USD", + "floor": "0" + } + ], + "basis": "spend", + "amount": "1", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "3", + "flat_charges": "0", + "media_spend": "1", + "seller_total": "1", + "units": "1000/3" + } + } + }, + { + "id": "impression-minimum-after-fees", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ], + "bounds": { + "min_impressions": "2000000" + } + }, + "expected": { + "error": "outside_min_impressions" + } + }, + { + "id": "impression-maximum", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [], + "bounds": { + "max_impressions": "1999999" + } + }, + "expected": { + "error": "outside_max_impressions" + } + }, + { + "id": "inverted-budget-bounds", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [], + "bounds": { + "min_budget": "60000", + "max_budget": "50000" + } + }, + "expected": { + "error": "inverted_bounds" + } + }, + { + "id": "unknown-extension-is-not-implemented", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "seller.example:engagement", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "unsupported_cost_method" + } + }, + { + "id": "flat-cost-method-is-not-a-flat-fee", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "FLAT", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "unsupported_cost_method" + } + }, + { + "id": "tax-inclusive-needs-separate-rule", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [], + "tax_inclusive": true + }, + "expected": { + "error": "unsupported_field" + } + }, + { + "id": "print-adjustments-need-separate-rule", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "agency", + "value_pct": "-15" + } + ] + }, + "expected": { + "error": "unsupported_field" + } + }, + { + "id": "cpmv-uses-thousand-viewable-units", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPMV", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "units": "2000000" + } + } + }, + { + "id": "cpmv-does-not-imply-all-impressions", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPMV", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [], + "bounds": { + "min_impressions": "1" + } + }, + "expected": { + "error": "unsupported_impression_bound" + } + } + ] +} diff --git a/scripts/check_pricing_fixtures.py b/scripts/check_pricing_fixtures.py new file mode 100644 index 0000000..6c3b097 --- /dev/null +++ b/scripts/check_pricing_fixtures.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Executable discussion profile for #30, #94 and #115, not a wire validator.""" + +import argparse +import json +import re +from fractions import Fraction +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DECIMAL = re.compile(r"-?\d{1,18}(?:\.\d{1,9})?\Z") + + +class ProfileError(ValueError): + pass + + +def require(condition, code): + if not condition: + raise ProfileError(code) + + +def number(value): + # Decimal strings make these test vectors independent of binary floats. + require(isinstance(value, str) and DECIMAL.fullmatch(value), "invalid_number") + return Fraction(value) + + +def shape(obj, required, optional=()): + require(isinstance(obj, dict), "invalid_shape") + require(set(required) <= obj.keys(), "missing_field") + require(obj.keys() <= set(required) | set(optional), "unsupported_field") + + +def resolve(case): + """Resolve already selected adjustments using exact rational arithmetic. + + Input names are fixture adapter keys, not proposed OpenProposal fields. + Only a caller that has established acceptance may supply accepted_counter. + """ + shape(case, ("pricing", "basis", "amount", "currency", "adjustments"), + ("pricing_id", "accepted_counter", "selection_key", "bounds")) + rows = case["pricing"] + require(isinstance(rows, list) and rows, "missing_pricing") + for row in rows: + shape(row, ("pricing_id", "cost_method", "gross_rate", "currency"), ("floor",)) + require(isinstance(row["pricing_id"], str) and row["pricing_id"], "invalid_pricing_id") + ids = [row["pricing_id"] for row in rows] + require(len(ids) == len(set(ids)), "duplicate_pricing_id") + require(len(rows) == 1 or "pricing_id" in case, "pricing_choice_required") + chosen = case.get("pricing_id", ids[0]) + require(chosen in ids, "unknown_pricing_id") + row = rows[ids.index(chosen)] + method = row["cost_method"] + require(isinstance(method, str), "invalid_shape") + method = method.upper() + require(method in ("CPM", "CPMV", "CPC"), "unsupported_cost_method") + require(isinstance(row["currency"], str) and re.fullmatch(r"[A-Z]{3}", row["currency"]), + "invalid_currency") + require(case["currency"] == row["currency"], "currency_mismatch") + require(case["basis"] in ("spend", "units"), "unsupported_basis") + amount = number(case["amount"]) + require(amount > 0, "nonpositive_amount") + rate = number(row["gross_rate"]) + require(rate > 0, "nonpositive_rate") + cpm_delta, flat, factors = Fraction(0), Fraction(0), [] + adjustments = case["adjustments"] + require(isinstance(adjustments, list), "invalid_shape") + adjustment_ids = set() + for adjustment in adjustments: + shape(adjustment, ("id",), ("delta_cpm", "delta_pct", "delta_flat")) + key = adjustment["id"] + require(isinstance(key, str) and key, "invalid_adjustment_id") + require(key not in adjustment_ids, "duplicate_adjustment") + adjustment_ids.add(key) + require(len(adjustment) > 1, "empty_adjustment") + if "delta_cpm" in adjustment: + require(method in ("CPM", "CPMV"), "incompatible_delta_cpm") + cpm_delta += number(adjustment["delta_cpm"]) + if "delta_flat" in adjustment: + fee = number(adjustment["delta_flat"]) + require(fee >= 0, "unsupported_flat_credit") + flat += fee + if "delta_pct" in adjustment: + factor = 1 + number(adjustment["delta_pct"]) + require(factor > 0, "nonpositive_percentage_factor") + factors.append(factor) + rate += cpm_delta + for factor in factors: + rate *= factor + if "accepted_counter" in case: + counter = case["accepted_counter"] + shape(counter, ("rate", "selection_key")) + require(isinstance(case.get("selection_key"), str) and case["selection_key"], + "counter_binding_required") + require(counter["selection_key"] == case["selection_key"], "stale_counter") + # The accepted counter is the final composed rate, not a new base rate. + rate = number(counter["rate"]) + require(rate > 0, "nonpositive_rate") + floor = number(row.get("floor", "0")) + require(floor >= 0, "negative_floor") + require(rate >= floor, "below_floor") + rate_per = 1000 if method in ("CPM", "CPMV") else 1 + if case["basis"] == "spend": + total = amount + require(total >= flat, "fees_exceed_spend") + media = total - flat + units = media / rate * rate_per + else: + units = amount + media = units / rate_per * rate + total = media + flat + bounds = case.get("bounds", {}) + shape(bounds, (), ("min_budget", "max_budget", "min_spend", "min_impressions", "max_impressions")) + values = {key: number(value) for key, value in bounds.items()} + require(all(value >= 0 for value in values.values()), "negative_bound") + for low, high in (("min_budget", "max_budget"), ("min_impressions", "max_impressions")): + require(low not in values or high not in values or values[low] <= values[high], + "inverted_bounds") + require("min_spend" not in values or "max_budget" not in values or + values["min_spend"] <= values["max_budget"], "inverted_bounds") + for name, value in values.items(): + actual = total + if name.endswith("impressions"): + require(method == "CPM", "unsupported_impression_bound") + actual = units + require(actual >= value if name.startswith("min_") else actual <= value, + "outside_" + name) + return {"pricing_id": chosen, "currency": row["currency"], + **{key: str(value) for key, value in { + "agreed_rate": rate, "flat_charges": flat, "media_spend": media, + "seller_total": total, "units": units}.items()}} + + +def evaluate(case): + try: + return {"result": resolve(case)} + except ProfileError as error: + return {"error": str(error)} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true", help="emit per-vector evidence") + args = parser.parse_args() + corpus = json.loads((ROOT / "fixtures/pricing/cases.json").read_text()) + results = [] + for vector in corpus["cases"]: + actual = evaluate(vector["input"]) + results.append({"id": vector["id"], "passed": actual == vector["expected"], + "actual": actual, "expected": vector["expected"]}) + passed = sum(result["passed"] for result in results) + if args.json: + print(json.dumps({"profile": corpus["profile"], "passed": passed, + "total": len(results), "cases": results}, indent=2)) + else: + for result in results: + print(("PASS " if result["passed"] else "FAIL ") + result["id"]) + if not result["passed"]: + print(json.dumps(result, indent=2)) + print(f"{passed}/{len(results)} pricing discussion vectors pass") + return int(passed != len(results)) + + +if __name__ == "__main__": + raise SystemExit(main()) From 7ab6d2b39d688b92790336cf7c2f2604f012a605 Mon Sep 17 00:00:00 2001 From: Aleksander Sekowski Date: Sat, 24 Oct 2026 10:46:41 -0700 Subject: [PATCH 15/47] Separate creative inspection from production notifications --- .github/workflows/validate.yml | 10 ++ docs/creative-inspection-fixtures.md | 137 ++++++++++++++ examples/line-item-ctv.yaml | 3 + fixtures/creative/inline.xml | 45 +++++ fixtures/creative/wrapper.xml | 20 +++ scripts/check_creative_fixtures.py | 142 +++++++++++++++ scripts/check_creative_preview.mjs | 256 +++++++++++++++++++++++++++ scripts/inspect_creative_fixture.py | 146 +++++++++++++++ spec/CHANGELOG.md | 4 + spec/openproposal-3.0.md | 40 +++++ 10 files changed, 803 insertions(+) create mode 100644 docs/creative-inspection-fixtures.md create mode 100644 fixtures/creative/inline.xml create mode 100644 fixtures/creative/wrapper.xml create mode 100644 scripts/check_creative_fixtures.py create mode 100644 scripts/check_creative_preview.mjs create mode 100644 scripts/inspect_creative_fixture.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1ea9da9..5a32d10 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -67,3 +67,13 @@ jobs: python-version: "3.12" - name: Check every field in the body appears in Appendix A run: python3 scripts/check_appendix.py + + creative-inspection: + name: Creative inspection fixtures + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python3 scripts/check_creative_fixtures.py diff --git a/docs/creative-inspection-fixtures.md b/docs/creative-inspection-fixtures.md new file mode 100644 index 0000000..b532882 --- /dev/null +++ b/docs/creative-inspection-fixtures.md @@ -0,0 +1,137 @@ +# Inspect creative without reporting live delivery + +This contribution implements the proposed wording in +[#42](https://github.com/IABTechLab/OpenProposal/issues/42) and supplies synthetic +fixtures that exercise it. It also supports the distinction raised in +[#32](https://github.com/IABTechLab/OpenProposal/issues/32): resolving a format, +fetching a document, checking conformance and receiving seller approval are +separate results. + +The normative change adds the inspection subsection to section 5.4 and a note +to the CTV example. It adds no fields or enum values. The appendix field +indexes therefore remain unchanged. + +## Reproduce the document inspection + +``` +python3 scripts/check_creative_fixtures.py +python3 scripts/check_creative_fixtures.py --json +``` + +The standard-library runner starts a loopback HTTP server. The server records +every request, including requests to synthetic notification traps. Nothing +loads a campaign URL. The two VAST templates use fixture substitution tokens +that the runner replaces with the server's loopback address. + +The reference inspector retrieves only exact document URLs listed by the test +harness. It follows a single-ad wrapper chain, hashes retrieved bytes and +parses XML. It does not execute code, fetch media, probe event URLs, expand +macros, use proxy environment variables, or automatically follow redirects. +An HTTP redirect can itself target an event endpoint. It must not become an +unchecked route around the retrieval policy. + +Successful wrapper traversal reaches the inline document with no requests to +its impression, playback, click, viewability, verification or error endpoints. +Failure cases include cycles, missing documents, malformed XML, a redirect to +an event URL, excessive size, excessive depth and an event URL also used as a +wrapper target. Unresolved macros, unsupported encodings, DTDs and ad pods are +reported as not checked within this limited profile. Missing wrapper targets +and malformed XML fail their specific structural checks. + +The server-side positive control deliberately sends GET and HEAD to a local +notification trap and verifies both are recorded. This establishes that zero +notification requests in the inspection cases is an observable test result. +Neither response availability nor HTTP method makes an event request safe. + +## Reproduce the browser preview + +Requires Node.js 22 or later and an installed Chrome or Chromium executable. +Set `CHROME_BIN` if it is not at one of the common paths checked by the runner. +`CREATIVE_CACHE_DIR` can place the temporary browser profile in a chosen cache +directory. The directory must already exist. The profile is removed on exit. + +``` +node scripts/check_creative_preview.mjs +node scripts/check_creative_preview.mjs --json +``` + +The browser runs synthetic creative that attempts a GET, HEAD, POST, beacon, +XHR, image, script, nested frame, CSS image, video poster, form submission, +navigation and WebSocket connection. Each mechanism has a positive control +that must reach its loopback notification trap using the expected method. +Each isolated case must execute the same attempt and produce zero requests +at that trap. A receipt includes the browser version, attempted operation, +server observations, blocked requests and a 500 ms observation interval +after the synchronous attempt. + +The isolated preview combines a sandboxed frame, a restrictive Content +Security Policy, browser request interception and an independent HTTP proxy +that permits only the exact preview-document request. Proxy bypass for +loopback is disabled. The proxy rejects CONNECT except for the WebSocket +positive control's loopback tunnel. It permits a control's specific loopback +event URL only while that control runs. It never forwards to external +destinations. A separate navigation case disables CSP and browser interception +to verify that the independent gate itself blocks the notification. + +The independent gate is essential to this demonstration. An initial test +using CSP and target-scoped browser interception alone allowed frame +navigation to reach the synthetic event endpoint in Chrome. The navigation +case remains in the suite and must be blocked outside the renderer too. +This finding supports a requirement about observable notifications rather +than prescribing a particular browser API as sufficient isolation. + +These are finite synthetic tests, not proof that this harness safely executes +arbitrary third-party code. It is not a production sandbox. Delayed traffic, +all possible protocols, browser vulnerabilities, WebRTC, native applications +and real verification SDKs are outside the test coverage. A production +inspector needs enforcement for its entire execution environment and must +decline a check it cannot isolate. The browser fixture demonstrates request +suppression; it does not demonstrate video decoding or playback conformance. + +## Report the exact check and its evidence + +The JSON receipt is a non-normative sidecar for these tests, not a new +OpenProposal object or an adopted readiness protocol. Each result names the +check, its outcome and its reason. Retrieved documents carry their SHA-256 +hash, URL and byte count. The receipt also identifies the asset, inspection +time, root URL, depth/size limits, redirect policy and macro context. + +- `passed`: the named check ran and met that check's condition. Parsing XML + does not pass VAST schema validation. Reaching `InLine` does not pass + playback, media decode, seller specs or seller approval. +- `failed`: the named check ran and found a concrete violation, such as + malformed XML or a cycle in the retrieved wrapper chain. +- `not_checked`: the check did not establish an outcome. The receipt states + whether data was missing, retrieval failed, policy blocked it or the + implementation did not support the check. These reasons do not establish + that the creative itself is invalid. + +A required check set is satisfied only if every named check has at least one +result and all its results passed. Missing, failed or not-checked results +cannot satisfy it. An empty requirement list cannot establish satisfaction. +Even satisfied inspection checks do not establish seller acceptance. +`readiness` remains `not_assessed` in this first profile. + +The inspector leaves VAST schema conformance, resolved format membership, +seller-spec conformance, media decode, verification execution, live playback +and seller approval explicitly not checked. A minimal `` fixture is +included to prove that successful traversal never becomes a schema pass. + +## Boundaries for the next creative contribution + +Readiness needs a declared check set for the resolved format and delivery +protocol. An audit needs the exact asset bytes, resolved wrapper documents, +seller-spec version, execution context, checker version and evidence expiry. +Mutable URLs or changed specifications cannot inherit an earlier pass without +a revalidation rule. Those are follow-up decisions, not implicit guarantees +of this fixture receipt. + +This proposal does not require inspectors to fetch documents or offer a +preview. It does not change VAST's live-delivery tracking requirements. +Synthetic event delivery to a documented receiver test mode would need its +own evidence that the receiver excludes it from production measurement. +An inspector-owned `test=true` parameter cannot establish that fact. + +References: [VAST 4.3, sections 2.3.4 and 2.3.6](https://iabtechlab.com/wp-content/uploads/2022/09/VAST_4.3.pdf), +[Content Security Policy](https://www.w3.org/TR/CSP3/), and +[Chrome DevTools Fetch domain](https://chromedevtools.github.io/devtools-protocol/tot/Fetch/). diff --git a/examples/line-item-ctv.yaml b/examples/line-item-ctv.yaml index fda3994..0487e17 100644 --- a/examples/line-item-ctv.yaml +++ b/examples/line-item-ctv.yaml @@ -33,3 +33,6 @@ buying_route: SSP_PLATFORM transaction_mechanism: { available: ["PRIVATE_AUCTION"], included: ["PRIVATE_AUCTION"] } serving_mode: ssai correlation_id: "ep-q4-2026-commitment/weather-app-ctv" + +# Inspection and previews must not send production tracking or error notifications. +# See the creative inspection requirements in spec section 5.4. diff --git a/fixtures/creative/inline.xml b/fixtures/creative/inline.xml new file mode 100644 index 0000000..84a50ec --- /dev/null +++ b/fixtures/creative/inline.xml @@ -0,0 +1,45 @@ + + + + + OpenProposal inspection fixture + Synthetic inspection asset + + synthetic-serving-id + + + + + + + + + + synthetic + + + + + + + + synthetic-asset + + 00:00:15 + + + + + + + + + + + + + + + + + diff --git a/fixtures/creative/wrapper.xml b/fixtures/creative/wrapper.xml new file mode 100644 index 0000000..c999551 --- /dev/null +++ b/fixtures/creative/wrapper.xml @@ -0,0 +1,20 @@ + + + + + OpenProposal inspection fixture + + + + + + + + + + + + + + + diff --git a/scripts/check_creative_fixtures.py b/scripts/check_creative_fixtures.py new file mode 100644 index 0000000..24d440e --- /dev/null +++ b/scripts/check_creative_fixtures.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Exercise inspection against local resources and local notification traps.""" + +import argparse +import json +import threading +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from inspect_creative_fixture import FixtureTransport, checks_satisfied, inspect + +ROOT = Path(__file__).resolve().parents[1] + + +def run(): + requests, routes = [], {} + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + requests.append({"method": "GET", "path": self.path}) + if self.path == "/redirect": + self.send_response(302) + self.send_header("Location", "/events/redirect") + self.end_headers() + return + status, body = routes.get(self.path, (404, b"not found")) + self.send_response(status) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_HEAD(self): + requests.append({"method": "HEAD", "path": self.path}) + self.send_response(204) + self.end_headers() + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = "http://127.0.0.1:" + str(server.server_port) + inline = (ROOT / "fixtures/creative/inline.xml").read_text().replace("{{BASE}}", base) + wrapper = (ROOT / "fixtures/creative/wrapper.xml").read_text().replace("{{BASE}}", base) + + def wrap(target): + return wrapper.replace("{{TARGET}}", base + target).encode() + + routes.update({ + "/inline": (200, inline.encode()), "/wrapper": (200, wrap("/inline")), + "/cycle": (200, wrap("/cycle")), "/broken-wrapper": (200, wrap("/missing")), + "/external": (200, wrapper.replace("{{TARGET}}", "https://unreachable.example.test/ad").encode()), + "/macro": (200, wrap("/inline?context=[PAGEURL]")), + "/malformed": (200, b""), + "/not-vast": (200, b""), + "/doctype": (200, b']>&x;'), + "/utf16": (200, inline.encode("utf-16")), + "/large": (200, b" " * 65537), "/empty": (200, b''), + "/pod": (200, b''), + "/missing-target": (200, b''), + "/both": (200, b''), + "/dual-role": (200, wrap("/events/wrapper-impression")), + "/events/wrapper-impression": (200, inline.encode()), + "/inline-minimal": (200, b''), + }) + allowed = {base + path for path in routes if not path.startswith("/events/")} | {base + "/missing", base + "/redirect"} + definitions = [ + ("wrapper-to-inline", "/wrapper", "wrapper_resolution", "passed", ["/wrapper", "/inline"], 5), + ("inline-document", "/inline", "wrapper_resolution", "passed", ["/inline"], 5), + ("wrapper-cycle", "/cycle", "wrapper_resolution", "failed", ["/cycle"], 5), + ("wrapper-http-failure", "/broken-wrapper", "document_retrieval", "not_checked", ["/broken-wrapper", "/missing"], 5), + ("redirect-to-event-blocked", "/redirect", "document_retrieval", "not_checked", ["/redirect"], 5), + ("external-target-blocked", "/external", "document_retrieval", "not_checked", ["/external"], 5), + ("unresolved-macro-blocked", "/macro", "document_retrieval", "not_checked", ["/macro"], 5), + ("malformed-xml", "/malformed", "xml_well_formedness", "failed", ["/malformed"], 5), + ("wrong-root", "/not-vast", "vast_root", "failed", ["/not-vast"], 5), + ("doctype-not-executed", "/doctype", "xml_well_formedness", "not_checked", ["/doctype"], 5), + ("unsupported-encoding", "/utf16", "xml_well_formedness", "not_checked", ["/utf16"], 5), + ("document-size-limit", "/large", "document_retrieval", "not_checked", ["/large"], 5), + ("empty-response-not-conformance", "/empty", "wrapper_resolution", "not_checked", ["/empty"], 5), + ("ad-pod-outside-profile", "/pod", "wrapper_resolution", "not_checked", ["/pod"], 5), + ("wrapper-target-missing", "/missing-target", "wrapper_resolution", "failed", ["/missing-target"], 5), + ("ambiguous-ad-shape", "/both", "wrapper_resolution", "failed", ["/both"], 5), + ("document-count-limit", "/wrapper", "wrapper_resolution", "not_checked", ["/wrapper"], 1), + ("event-also-used-as-wrapper-target", "/dual-role", "wrapper_resolution", "not_checked", ["/dual-role"], 5), + ("minimal-inline-is-not-schema-validation", "/inline-minimal", "vast_schema_conformance", "not_checked", ["/inline-minimal"], 5), + ] + results = [] + try: + for name, path, check, outcome, expected_requests, limit in definitions: + requests.clear() + # Dual-role case deliberately allows the URL to prove the semantic + # event guard stops it before the transport allowlist is consulted. + permitted = allowed | ({base + "/events/wrapper-impression"} if path == "/dual-role" else set()) + receipt = inspect(base + path, FixtureTransport(permitted), max_documents=limit) + matches = [item for item in receipt["checks"] if item["check"] == check] + notifications = [item for item in requests if item["path"].startswith("/events/")] + passed = (bool(matches) and matches[-1]["outcome"] == outcome and not notifications + and [item["path"] for item in requests] == expected_requests + and not checks_satisfied(receipt, ["vast_schema_conformance"]) + and not checks_satisfied(receipt, ["unknown_required_check"]) + and not checks_satisfied(receipt, []) and receipt["readiness"] == "not_assessed") + if path == "/wrapper" and limit == 5: + passed = passed and checks_satisfied(receipt, ["document_retrieval", "xml_well_formedness", "wrapper_resolution"]) + passed = passed and len(receipt["identified_event_urls"]) == 13 + results.append({"id": name, "passed": bool(passed), "expected_check": check, + "expected_outcome": outcome, "server_requests": requests.copy(), "receipt": receipt}) + # A positive control proves the trap records notifications, including HEAD. + # These endpoints are synthetic and loopback-only, never campaign URLs. + requests.clear() + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + for method in ("GET", "HEAD"): + try: + with opener.open(urllib.request.Request(base + "/events/control", method=method)): + pass + except urllib.error.HTTPError: + pass + results.append({"id": "notification-trap-positive-control", "passed": requests == [ + {"method": "GET", "path": "/events/control"}, {"method": "HEAD", "path": "/events/control"}], + "server_requests": requests.copy()}) + return {"profile": "creative-inspection-discussion-1", "passed": sum(item["passed"] for item in results), + "total": len(results), "cases": results} + finally: + server.shutdown() + server.server_close() + thread.join() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + report = run() + if args.json: + print(json.dumps(report, indent=2)) + else: + for case in report["cases"]: + print(("PASS " if case["passed"] else "FAIL ") + case["id"]) + print(f'{report["passed"]}/{report["total"]} creative inspection cases pass') + raise SystemExit(report["passed"] != report["total"]) diff --git a/scripts/check_creative_preview.mjs b/scripts/check_creative_preview.mjs new file mode 100644 index 0000000..c4dbb14 --- /dev/null +++ b/scripts/check_creative_preview.mjs @@ -0,0 +1,256 @@ +#!/usr/bin/env node +// Synthetic loopback preview tests. Never load live creative with this harness. +import { spawn } from 'node:child_process'; +import { createServer, request as httpRequest } from 'node:http'; +import { connect } from 'node:net'; +import { mkdtemp, readFile, rm, access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { once } from 'node:events'; + +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); +const attempts = { + 'fetch-get': `fetch(url).catch(() => {})`, + 'fetch-head': `fetch(url, {method: 'HEAD'}).catch(() => {})`, + 'fetch-post': `fetch(url, {method: 'POST', body: 'synthetic'}).catch(() => {})`, + beacon: `navigator.sendBeacon(url, 'synthetic')`, + xhr: `const x = new XMLHttpRequest(); x.open('GET', url); x.send()`, + image: `const x = new Image(); x.src = url; document.body.append(x)`, + script: `const x = document.createElement('script'); x.src = url; document.body.append(x)`, + iframe: `const x = document.createElement('iframe'); x.src = url; document.body.append(x)`, + 'css-image': `const x = document.createElement('div'); x.style.cssText = 'width:40px;height:40px;background-image:url(' + url + ')'; document.body.append(x)`, + 'video-poster': `const x = document.createElement('video'); x.poster = url; document.body.append(x)`, + form: `const x = document.createElement('form'); x.method = 'POST'; x.action = url; document.body.append(x); x.submit()`, + navigation: `location.href = url`, + websocket: `const x = new WebSocket(url.replace('http:', 'ws:')); x.onerror = () => {}`, +}; +const jsonMode = process.argv.includes('--json'); +const csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"; +const escapeAttribute = value => value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<'); +let base; +const received = []; +const gateBlocked = []; +let allowedRequests = new Set(); +let allowControlTunnel = false; +// A separate HTTP proxy enforces the fixture policy across renderer changes. +// CSP and target-scoped interception alone missed a navigation in testing. +const gate = createServer((request, response) => { + let url; + try { url = new URL(request.url); } catch { response.writeHead(400).end(); return; } + if (url.origin !== base || !allowedRequests.has(request.method + ' ' + url.href)) { + gateBlocked.push({method: request.method, url: url.href}); + response.writeHead(403).end(); return; + } + const upstream = httpRequest(url, {method: request.method, headers: request.headers}, result => { + response.writeHead(result.statusCode, result.headers); result.pipe(response); + }); + upstream.on('error', () => response.writeHead(502).end()); + request.pipe(upstream); +}); +gate.on('connect', (request, socket, head) => { + if (allowControlTunnel && request.url === new URL(base).host) { + const upstream = connect(server.address().port, '127.0.0.1', () => { + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head.length) upstream.write(head); + socket.pipe(upstream); upstream.pipe(socket); + }); + upstream.on('error', () => socket.destroy()); socket.on('error', () => upstream.destroy()); + socket.on('close', () => upstream.destroy()); + return; + } + gateBlocked.push({method: 'CONNECT', url: request.url}); + socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); +}); +gate.on('upgrade', (request, socket, head) => { + let url; + try { url = new URL(request.url); } catch { socket.destroy(); return; } + // WebSocket proxy requests use ws: while the fixture allowlist uses http:. + const normalized = url.href.replace(/^ws:/, 'http:'); + if (!normalized.startsWith(base + '/') || !allowedRequests.has('UPGRADE ' + normalized)) { + gateBlocked.push({method: 'UPGRADE', url: url.href}); + socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); return; + } + const upstream = connect(server.address().port, '127.0.0.1', () => { + upstream.write(request.method + ' ' + url.pathname + ' HTTP/1.1\r\n' + + Object.entries(request.headers).map(([key, value]) => key + ': ' + value).join('\r\n') + '\r\n\r\n'); + if (head.length) upstream.write(head); + socket.pipe(upstream); upstream.pipe(socket); + }); + upstream.on('error', () => socket.destroy()); socket.on('error', () => upstream.destroy()); + socket.on('close', () => upstream.destroy()); +}); +const server = createServer((request, response) => { + const url = new URL(request.url, base); + if (url.pathname === '/preview') { + const id = url.searchParams.get('case'); + const isolated = url.searchParams.get('mode') === 'isolated'; + const operation = attempts[id]; + if (!operation) { response.writeHead(400).end(); return; } + const eventUrl = base + '/events/' + id; + const frame = `${isolated ? `` : ''}`; + const html = `Synthetic creative inspection + + `; + response.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' }); + response.end(html); + } else if (url.pathname.startsWith('/events/')) { + received.push({ method: request.method, path: url.pathname }); + response.writeHead(200, { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' }); + response.end('synthetic notification trap'); + } else { + response.writeHead(404).end(); + } +}); +server.on('upgrade', (request, socket) => { + received.push({ method: 'UPGRADE', path: new URL(request.url, base).pathname }); + socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); +}); + +class CDP { + constructor(socket) { + this.socket = socket; this.id = 0; this.pending = new Map(); this.handlers = []; + socket.addEventListener('message', ({data}) => { + const message = JSON.parse(data); + if (message.id) { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); clearTimeout(pending.timer); + if (message.error) pending.reject(new Error(JSON.stringify(message.error))); + else pending.resolve(message.result); + } else for (const handler of this.handlers) handler(message); + }); + } + call(method, params = {}, sessionId) { + const id = ++this.id; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { this.pending.delete(id); reject(new Error('CDP timed out: ' + method)); }, 10000); + this.pending.set(id, {resolve, reject, timer}); + this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? {sessionId} : {}) })); + }); + } + close() { this.socket.close(); } +} + +async function findChrome() { + const paths = process.env.CHROME_BIN ? [process.env.CHROME_BIN] : [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', + ]; + for (const path of paths) { try { await access(path); return path; } catch {} } + throw new Error('Chrome not found. Set CHROME_BIN to a Chrome/Chromium executable.'); +} + +let chrome, profile, browser, cdp; +const cases = []; +try { + const executable = await findChrome(); + profile = await mkdtemp(join(process.env.CREATIVE_CACHE_DIR || tmpdir(), 'openproposal-preview-')); + server.listen(0, '127.0.0.1'); await once(server, 'listening'); + base = 'http://127.0.0.1:' + server.address().port; + gate.listen(0, '127.0.0.1'); await once(gate, 'listening'); + chrome = spawn(executable, ['--headless=new', '--remote-debugging-port=0', '--user-data-dir=' + profile, + '--no-first-run', '--no-default-browser-check', '--disable-background-networking', + '--disable-component-update', '--disable-sync', '--disable-quic', + '--proxy-server=http://127.0.0.1:' + gate.address().port, '--proxy-bypass-list=<-loopback>', + 'about:blank'], {stdio: 'ignore'}); + let port; + for (let i = 0; i < 100; i++) { + try { port = (await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]; break; } catch {} + if (chrome.exitCode !== null) throw new Error('Chrome exited before DevTools started'); + await delay(100); + } + if (!port) throw new Error('Chrome did not start DevTools'); + const endpoint = 'http://127.0.0.1:' + port; + browser = await (await fetch(endpoint + '/json/version')).json(); + for (const id of Object.keys(attempts)) { + for (const mode of ['control', 'isolated', ...(id === 'navigation' ? ['network-gate'] : [])]) { + const target = await (await fetch(endpoint + '/json/new?about:blank', {method: 'PUT'})).json(); + const socket = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, {once:true}); socket.addEventListener('error', reject, {once:true}); }); + cdp = new CDP(socket); + const blocked = [], protocolErrors = [], pendingEvents = new Set(); + const previewUrl = base + '/preview?case=' + id + '&mode=' + mode; + const isolated = mode !== 'control'; + const expectedMethod = {'fetch-head':'HEAD', 'fetch-post':'POST', beacon:'POST', form:'POST', websocket:'UPGRADE'}[id] || 'GET'; + allowedRequests = new Set(['GET ' + previewUrl]); + if (!isolated) allowedRequests.add(expectedMethod + ' ' + base + '/events/' + id); + allowControlTunnel = id === 'websocket' && mode === 'control'; + gateBlocked.length = 0; + async function guard(sessionId) { + await cdp.call('Fetch.enable', {patterns: [{urlPattern: '*', requestStage: 'Request'}]}, sessionId); + await cdp.call('Target.setAutoAttach', {autoAttach: true, waitForDebuggerOnStart: true, flatten: true}, sessionId); + } + cdp.handlers.push(message => { + const work = (async () => { + if (message.method === 'Fetch.requestPaused') { + const {requestId, request} = message.params; + if (request.url === previewUrl && request.method === 'GET') { + await cdp.call('Fetch.continueRequest', {requestId}, message.sessionId); + } else { + blocked.push({url: request.url, method: request.method}); + await cdp.call('Fetch.failRequest', {requestId, errorReason: 'BlockedByClient'}, message.sessionId); + } + } else if (message.method === 'Target.attachedToTarget' && isolated) { + await guard(message.params.sessionId); + await cdp.call('Runtime.runIfWaitingForDebugger', {}, message.params.sessionId); + } + })().catch(error => protocolErrors.push(error.message)); + pendingEvents.add(work); work.finally(() => pendingEvents.delete(work)); + }); + await cdp.call('Page.enable'); await cdp.call('Runtime.enable'); + if (mode === 'isolated') await guard(); + received.length = 0; + await cdp.call('Page.navigate', {url: previewUrl}); + let messages = []; + for (let i = 0; i < 100; i++) { + const state = await cdp.call('Runtime.evaluate', {expression: 'window.fixtureMessages || []', returnByValue: true}); + messages = state.result.value || []; + if (messages.some(message => message.attempted === id)) break; + await delay(25); + } + // Finite observation window is reported with the receipt, not a promise + // that arbitrary creative could not schedule traffic at a later time. + await delay(500); + await Promise.all([...pendingEvents]); + const state = await cdp.call('Runtime.evaluate', {expression: 'window.fixtureMessages || []', returnByValue: true}); + messages = state.result.value || []; + const notifications = received.filter(request => request.path === '/events/' + id); + const attempted = messages.some(message => message.attempted === id); + const passed = attempted && protocolErrors.length === 0 && (isolated ? notifications.length === 0 : notifications.some(request => request.method === expectedMethod)); + cases.push({id: id + '/' + mode, passed, attempted, expected_method: expectedMethod, + server_notifications: notifications, blocked_requests: blocked, + network_gate_blocks: gateBlocked.slice(), messages, + protocol_errors: protocolErrors, observation_ms_after_attempt: 500}); + await fetch(endpoint + '/json/close/' + target.id); + cdp.close(); cdp = null; + } + } + const report = {profile: 'synthetic-browser-inspection-1', browser: browser.Browser, + tested_at: new Date().toISOString(), passed: cases.filter(item => item.passed).length, + total: cases.length, cases}; + if (jsonMode) console.log(JSON.stringify(report, null, 2)); + else { + for (const item of cases) console.log((item.passed ? 'PASS ' : 'FAIL ') + item.id); + console.log(`${report.passed}/${report.total} synthetic browser cases pass (${browser.Browser})`); + } + if (report.passed !== report.total) process.exitCode = 1; +} finally { + cdp?.close(); + if (chrome && chrome.exitCode === null && chrome.signalCode === null) { + chrome.kill('SIGTERM'); + await Promise.race([once(chrome, 'exit'), delay(3000)]); + if (chrome.exitCode === null && chrome.signalCode === null) { + chrome.kill('SIGKILL'); await once(chrome, 'exit'); + } + } + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + gate.closeAllConnections(); + await new Promise(resolve => gate.close(resolve)); + if (profile) await rm(profile, {recursive: true, force: true, maxRetries: 5, retryDelay: 100}); +} diff --git a/scripts/inspect_creative_fixture.py b/scripts/inspect_creative_fixture.py new file mode 100644 index 0000000..941dfb5 --- /dev/null +++ b/scripts/inspect_creative_fixture.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Bounded, fixture-only document inspection supporting OpenProposal #42. + +Not a production fetcher, player, VAST validator or creative-readiness service. +""" + +import hashlib +import urllib.error +import urllib.request +import xml.etree.ElementTree as ET +from datetime import datetime, timezone + +EVENT_ELEMENTS = {"Impression", "Error", "Tracking", "ClickTracking", "ClickThrough", + "CustomClick", "Viewable", "NotViewable", "ViewUndetermined"} + + +class Unchecked(Exception): + """The check could not run within the fixture's retrieval policy.""" + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +class FixtureTransport: + def __init__(self, documents, max_bytes=65536): + # Explicit exact URLs come from the local fixture server, not the XML. + # Callers must establish each URL is a document resource, not an event. + self.documents = frozenset(documents) + self.max_bytes = max_bytes + self.requests = [] + self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + + def get(self, url): + if url not in self.documents: + raise Unchecked("document URL is outside the fixture allowlist") + self.requests.append({"method": "GET", "url": url}) + try: + with self.opener.open(url, timeout=2) as response: + data = response.read(self.max_bytes + 1) + except (urllib.error.URLError, TimeoutError, OSError) as error: + raise Unchecked("document retrieval failed or redirected: " + type(error).__name__) from error + if len(data) > self.max_bytes: + raise Unchecked("document exceeds the inspection byte limit") + return data + + +def inspect(root_url, transport, *, asset_id="fixture-asset", max_documents=5): + checks, documents, event_urls, visited = [], [], set(), set() + + def record(name, outcome, reason, url=None): + check = {"check": name, "outcome": outcome, "reason": reason} + if url: + check["document_url"] = url + checks.append(check) + + url = root_url + for _ in range(max_documents): + if url in event_urls: + record("wrapper_resolution", "not_checked", "target is also identified as an event URL", url) + break + if url in visited: + record("wrapper_resolution", "failed", "wrapper cycle in retrieved documents", url) + break + visited.add(url) + try: + data = transport.get(url) + except Unchecked as error: + record("document_retrieval", "not_checked", str(error), url) + record("wrapper_resolution", "not_checked", "a required document was not retrieved", url) + break + record("document_retrieval", "passed", "retrieved the requested document bytes", url) + documents.append({"url": url, "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}) + try: + xml = data.decode("utf-8") + except UnicodeDecodeError: + record("xml_well_formedness", "not_checked", "fixture reader supports UTF-8 only", url) + record("wrapper_resolution", "not_checked", "document was not parsed", url) + break + if "\x00" in xml or "` or `verificationNotExecuted` URL. + +A skipped or blocked check MUST NOT be reported as passed. This section does +not waive any required checks. + +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 @@ -371,6 +407,10 @@ buyer commitment, and `settable` bounds. See See [`examples/line-item-ctv.yaml`](../examples/line-item-ctv.yaml). +Inspection of this line item's VAST creative, including wrapper retrieval and +previews, MUST follow the inspection requirements in section 5.4. Inspection +MUST NOT send production tracking or error notifications. + ### 6.4 Audio line item See [`examples/line-item-audio.yaml`](../examples/line-item-audio.yaml). From 841c9c8eead273f5541722f9ccf09d572db4fa31 Mon Sep 17 00:00:00 2001 From: Aleksander Sekowski Date: Sat, 24 Oct 2026 11:37:05 -0700 Subject: [PATCH 16/47] Keep floor eligibility outside the pricing arithmetic profile --- .../pricing-commitment-arithmetic.md | 197 +++++++----------- fixtures/pricing/cases.json | 125 +++++++++-- scripts/check_pricing_fixtures.py | 5 +- 3 files changed, 183 insertions(+), 144 deletions(-) diff --git a/docs/proposals/pricing-commitment-arithmetic.md b/docs/proposals/pricing-commitment-arithmetic.md index db0aa6a..4e8ac39 100644 --- a/docs/proposals/pricing-commitment-arithmetic.md +++ b/docs/proposals/pricing-commitment-arithmetic.md @@ -1,80 +1,52 @@ -# Resolve one price and account for seller fees once +# Account for seller fees after selecting one price -Implementation feedback for [#30](https://github.com/IABTechLab/OpenProposal/issues/30), -[#94](https://github.com/IABTechLab/OpenProposal/issues/94) and -[#115](https://github.com/IABTechLab/OpenProposal/issues/115), against draft -`44dfaaf618736219426c311ec997b6d37e94b950`. +Discussion wording for [#94](https://github.com/IABTechLab/OpenProposal/issues/94) +and [#115](https://github.com/IABTechLab/OpenProposal/issues/115), against draft +`44dfaaf618736219426c311ec997b6d37e94b950`. The arithmetic also exercises the +final-rate reading discussed in [#30](https://github.com/IABTechLab/OpenProposal/issues/30). +This is a proposed profile, not an adopted requirement or an OpenProposal wire +validator. The normative specification is unchanged. -This is a proposed calculation profile with executable discussion vectors. -It is not an adopted requirement, an OpenProposal schema, or evidence of a -defect in a deployed implementation. Its fixture keys are an adapter format. +## The fee interpretation changes the available media spend -## The proposals currently produce incompatible results +#94 treats `pricing[]` entries as alternatives. #115 adds seller fees as extra +entries and includes them in `commitment.amount`. An extra alternative cannot +simultaneously be an additive fee. Summing all entries instead loses the choice +of cost method, currency and transaction mechanism. -#94 makes `pricing[]` entries alternatives and selects exactly one. -#115 puts seller-billed fees into additional `pricing[]` entries and includes -them in `commitment.amount`. Treating a fee as another alternative does not -add it to the selected price. Summing all rows instead loses #94's choice of -cost method, currency and transaction mechanism. - -There is also an arithmetic consequence. At USD 25 CPM, a USD 50,000 spend -commitment containing a USD 2,500 production fee leaves USD 47,500 for media: +At USD 25 CPM, a USD 50,000 commitment containing a USD 2,500 production fee +leaves USD 47,500 for media: ``` media_spend = 50,000 - 2,500 = 47,500 USD derived_units = 47,500 / 25 * 1,000 = 1,900,000 impressions ``` -Computing units from the whole commitment gives 2,000,000 impressions. Those -units cost USD 50,000 before the fee, for a seller total of USD 52,500. -Therefore #115's statement that `derived_units` is unaffected needs revision -if `amount` includes charges not included in the unit rate. - -## Proposed wording for joint resolution +Two million impressions would cost USD 50,000 before the fee, or USD 52,500 +in total. Therefore #115's statement that `derived_units` is unaffected needs +revision if `amount` includes charges outside the unit rate. -This wording assumes adoption of the alternative-selection part of #94. -It supplies the missing fee basis in #115 and clarifies the counter described -in #30. The working group can decide these pieces together without adopting -the entire cost-method vocabulary. +## Proposed wording for sections 3.1, 5.6 and 5.6.1 -### Sections 3.1 and 5.6: select the price and compose the rate +This wording assumes adoption of the alternative-selection rule in #94. -`pricing[]` entries are alternative prices, not additive charges. A commitment -MUST resolve to exactly one entry under the selection rule in #94. Only that -entry determines the cost method, rate, floor and currency. An implementation -MUST NOT sum alternative entries to compute the buyer's charge. +`pricing[]` entries are alternative prices. A commitment MUST resolve to +exactly one entry under that rule. An implementation MUST NOT sum alternative +entries to compute the buyer's charge. A selected option's `delta_flat` is a one-time seller charge for that line -item commitment. It MUST NOT enter the unit rate or be represented as another -alternative price solely to make it additive. A cost already included in the -unit rate MUST NOT also be added as a flat charge. Each selected option that -is not already included contributes its charge once. - -Unless a final-rate counter has been accepted, the composed rate is the chosen -entry's `gross_rate`, plus applicable `delta_cpm` values, then multiplied by -each applicable `(1 + delta_pct)` factor. For this proposal, `delta_pct` is a -decimal fraction: `0.20` means a 20 percent increase. `delta_cpm` applies only -to CPM and CPMV. Flat charges are outside this calculation. - -An accepted counter stated as the final composed rate MUST become -`agreed_rate` without reapplying the option rate adjustments. It MUST be bound -to the proposal revision, chosen price and resolved selections to which the -seller assented. A changed selection or revision requires a new applicable -acceptance. This rule does not prescribe an acceptance protocol. The floor -comparison uses that same final composed rate. A final-rate counter does not -waive separately identified flat charges unless the accepted terms explicitly -change those charges. - -### Section 5.6.1: define the spend basis +item commitment. It MUST NOT enter the unit rate or become another pricing +alternative solely to make it additive. A cost already included in the unit +rate MUST NOT also be added as a flat charge. Each selected option that is not +already included contributes its charge once. For `basis = spend`, `amount` is the total pre-tax amount payable to the seller for this line item, including applicable seller flat charges. It excludes -amounts payable by the buyer to other parties. When the selected cost method -has a unit rate, the amount available for those units is: +amounts payable by the buyer to other parties. For a selected unit-rate method: ``` -flat_charges = sum of applicable one-time seller charges -media_spend = commitment.amount - flat_charges +flat_charges = sum of applicable one-time seller charges +media_spend = commitment.amount - flat_charges derived_units = media_spend / agreed_rate * rate_per ``` @@ -83,76 +55,63 @@ positive. Flat charges MUST NOT exceed the spend commitment. Zero remaining media spend yields zero informative units and still has to satisfy any applicable minimum-unit requirement. -For `basis = units`, the committed units remain binding and the corresponding -seller total is: +For `basis = units`, the committed units remain binding: ``` -media_spend = commitment.amount / rate_per * agreed_rate -seller_total = media_spend + flat_charges +media_spend = commitment.amount / rate_per * agreed_rate +seller_total = media_spend + flat_charges ``` `min_spend` and budget bounds apply to the seller total on the same pre-tax -basis. Impression bounds apply only when the quantity is actually measured -in impressions; clicks or viewable impressions MUST NOT silently be treated -as the same quantity. The selected entry's currency applies to the rate, -flat charges and budget bounds. No currency conversion is implied. - -The calculations above do not round informative quantities. A displayed -rounded estimate MUST NOT change the committed amount or imply additional -authorized spend. Invoice rounding and minor-unit settlement need a separate -rule if they are to be interoperable. - -### Deliberate limits of this first proposal - -- This profile addresses seller fees already expressible through selected - `delta_flat` options. It does not invent a general fee object. If #115 needs - mandatory charges that cannot use that structure, their representation - needs a separate decision. They cannot be silently omitted. -- The proposed `billing.tax_inclusive` branch of #115 needs a tax amount or a - deterministic tax calculation before its units can be derived. The runner - rejects that branch rather than assuming tax is zero. -- #103's print discounts and surcharges introduce separate calculation bases. - Its `value_pct` and conditions are not interpreted by this profile. -- Cost methods other than CPM, CPMV and CPC are not implemented here. An - unsupported profile case is not a declaration that an OpenProposal is - invalid. Extension definitions and transaction-mechanism eligibility stay - with #94. -- A passing calculation establishes arithmetic consistency only. It does - not establish seller acceptance, inventory reservation or an agreed order. - -## Worked counter example - -A base CPM of USD 30 plus USD 4, USD 1.50 and USD 3 option adjustments gives -USD 38.50. If the seller accepts USD 30 as the final rate for those selections, -the rate is USD 30. Applying the adjustments again incorrectly restores -USD 38.50. A separate USD 2,500 flat fee remains outside that rate: one million -committed impressions cost USD 32,500 in total. - -## Reproduce the vectors +basis. The selected entry's currency applies to rates, flat charges and budget +bounds. No currency conversion is implied. Informative unit estimates MUST NOT +be rounded in a way that changes the committed amount or authorizes extra spend. + +## Arithmetic inputs do not establish acceptance + +The adapter consumes resolved, field-qualified adjustments that are not already +included. Its provisional #30 calculation starts with the chosen `gross_rate`, +adds applicable `delta_cpm`, then multiplies by `(1 + delta_pct)` factors. +`0.20` means a 20 percent increase. Flat charges remain separate. + +If the caller supplies an already accepted final-rate counter, that rate replaces +the composed rate without applying option rate deltas again. The caller must +have established acceptance for the bound proposal revision and selection. +`selection_key` is an opaque fixture binding, not an acceptance protocol. + +The adapter does not evaluate floor eligibility. Every successful result carries +`floor_check: not_checked`. A posted floor is checked only for numeric shape and +nonnegativity. In particular, an explicitly accepted USD 19 rate against a posted +USD 20 floor remains USD 19 in the calculation. The floor does not veto acceptance +already established by the caller. + +How a counter resolves automatically, how selected adjustments compose the floor, +and how seller assent is authenticated remain in #30. That discussion proposes +applying rate adjustments to the base floor. This profile neither adopts that +proposal nor silently compares a final rate with an uncomposed floor. A successful +calculation never authorizes a commitment, reservation or agreed order. + +## Scope and reproduction ``` python3 scripts/check_pricing_fixtures.py python3 scripts/check_pricing_fixtures.py --json ``` -The corpus is [`fixtures/pricing/cases.json`](../../fixtures/pricing/cases.json). -Expected outcomes are fixed independently of the calculator. Cases cover -alternative selection, fee-inclusive spend, unit commitments, counters, -currency, floors, bounds, percentage composition and unsupported semantics. - -Adapter inputs contain only the already resolved selected adjustments that -are not included in the base price. They do not extract selections from a -proposal or implement #22. Adjustment IDs are field-qualified to avoid -counting the same resolved adjustment twice. The caller has already confirmed -mechanism eligibility and seller acceptance of any `accepted_counter`. -`selection_key` is an opaque test binding, not an OpenProposal field or an -implementation of revision authentication. - -Decimal strings and exact rational outputs avoid hidden binary floating point -rounding. For example, `"1000/3"` means the exact informative quantity one -thousand divided by three. This is a fixture convention, not a proposed JSON -wire type. Currency checks cover agreement and shape, not ISO-4217 membership. - -No normative body or field index is changed by this discussion corpus. If -the wording is adopted, update sections 3.1, 5.6 and 5.6.1, the affected field -descriptions in Appendices A and B, and the committed examples together. +[`fixtures/pricing/cases.json`](../../fixtures/pricing/cases.json) contains fixed +expected outcomes independent of the calculator. Decimal strings and rational +outputs avoid binary floating point rounding. `"1000/3"` is an exact informative +quantity, not a proposed JSON wire type. Currency checks cover agreement and +shape, not ISO-4217 membership. The caller supplies a mechanism-eligible choice. + +The profile implements CPM, CPMV and CPC only. It rejects unsupported tax-inclusive +inputs, #103 print adjustments and flat credits. It cannot infer a mandatory fee +that is not represented by the resolved options. General fee objects, tax amounts, +invoice rounding and cost-method extensions need separate decisions. Impression +bounds are checked only for CPM; clicks and viewable impressions are not silently +treated as the same quantity. Unsupported cases are not declarations that an +OpenProposal document is invalid. + +If the fee wording is adopted, update the affected body sections, field +descriptions in Appendices A and B, and committed examples together. The executable +profile is supporting discussion evidence, not a replacement for those changes. diff --git a/fixtures/pricing/cases.json b/fixtures/pricing/cases.json index 0c65d9e..94c6ed8 100644 --- a/fixtures/pricing/cases.json +++ b/fixtures/pricing/cases.json @@ -1,5 +1,5 @@ { - "profile": "pricing-discussion-1", + "profile": "pricing-discussion-2", "cases": [ { "id": "cpm-no-fees", @@ -26,7 +26,8 @@ "flat_charges": "0", "media_spend": "50000", "seller_total": "50000", - "units": "2000000" + "units": "2000000", + "floor_check": "not_checked" } } }, @@ -60,7 +61,8 @@ "flat_charges": "2500", "media_spend": "47500", "seller_total": "50000", - "units": "1900000" + "units": "1900000", + "floor_check": "not_checked" } } }, @@ -94,7 +96,8 @@ "flat_charges": "2500", "media_spend": "50000", "seller_total": "52500", - "units": "2000000" + "units": "2000000", + "floor_check": "not_checked" } } }, @@ -162,7 +165,8 @@ "flat_charges": "2500", "media_spend": "47500", "seller_total": "50000", - "units": "1900000" + "units": "1900000", + "floor_check": "not_checked" } } }, @@ -222,7 +226,8 @@ "flat_charges": "2500", "media_spend": "0", "seller_total": "2500", - "units": "0" + "units": "0", + "floor_check": "not_checked" } } }, @@ -260,7 +265,8 @@ "flat_charges": "3000", "media_spend": "47000", "seller_total": "50000", - "units": "1880000" + "units": "1880000", + "floor_check": "not_checked" } } }, @@ -358,7 +364,8 @@ "flat_charges": "0", "media_spend": "38500", "seller_total": "38500", - "units": "1000000" + "units": "1000000", + "floor_check": "not_checked" } } }, @@ -405,7 +412,8 @@ "flat_charges": "0", "media_spend": "30000", "seller_total": "30000", - "units": "1000000" + "units": "1000000", + "floor_check": "not_checked" } } }, @@ -456,7 +464,8 @@ "flat_charges": "2500", "media_spend": "30000", "seller_total": "32500", - "units": "1000000" + "units": "1000000", + "floor_check": "not_checked" } } }, @@ -525,7 +534,7 @@ } }, { - "id": "counter-below-final-floor", + "id": "accepted-counter-below-posted-floor", "input": { "pricing": [ { @@ -560,11 +569,20 @@ } }, "expected": { - "error": "below_floor" + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "19", + "flat_charges": "0", + "media_spend": "30000", + "seller_total": "30000", + "units": "30000000/19", + "floor_check": "not_checked" + } } }, { - "id": "floor-equality-allowed", + "id": "accepted-counter-at-posted-floor", "input": { "pricing": [ { @@ -606,7 +624,8 @@ "flat_charges": "0", "media_spend": "30000", "seller_total": "30000", - "units": "1500000" + "units": "1500000", + "floor_check": "not_checked" } } }, @@ -644,7 +663,8 @@ "flat_charges": "0", "media_spend": "36000", "seller_total": "36000", - "units": "1000000" + "units": "1000000", + "floor_check": "not_checked" } } }, @@ -686,7 +706,8 @@ "flat_charges": "2500", "media_spend": "27000", "seller_total": "29500", - "units": "1000000" + "units": "1000000", + "floor_check": "not_checked" } } }, @@ -763,7 +784,8 @@ "flat_charges": "0", "media_spend": "30000", "seller_total": "30000", - "units": "1000000" + "units": "1000000", + "floor_check": "not_checked" } } }, @@ -831,7 +853,8 @@ "flat_charges": "2500", "media_spend": "47500", "seller_total": "50000", - "units": "23750" + "units": "23750", + "floor_check": "not_checked" } } }, @@ -867,7 +890,8 @@ "flat_charges": "0", "media_spend": "50000", "seller_total": "50000", - "units": "2000000" + "units": "2000000", + "floor_check": "not_checked" } } }, @@ -1037,7 +1061,8 @@ "flat_charges": "0", "media_spend": "50000", "seller_total": "50000", - "units": "2500000" + "units": "2500000", + "floor_check": "not_checked" } } }, @@ -1192,7 +1217,8 @@ "flat_charges": "0", "media_spend": "3/10", "seller_total": "3/10", - "units": "3000" + "units": "3000", + "floor_check": "not_checked" } } }, @@ -1221,7 +1247,8 @@ "flat_charges": "0", "media_spend": "1", "seller_total": "1", - "units": "1000/3" + "units": "1000/3", + "floor_check": "not_checked" } } }, @@ -1418,7 +1445,8 @@ "flat_charges": "0", "media_spend": "50000", "seller_total": "50000", - "units": "2000000" + "units": "2000000", + "floor_check": "not_checked" } } }, @@ -1445,6 +1473,57 @@ "expected": { "error": "unsupported_impression_bound" } + }, + { + "id": "posted-floor-does-not-establish-eligibility", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "30" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "units": "2000000", + "floor_check": "not_checked" + } + } + }, + { + "id": "negative-posted-floor-is-malformed-metadata", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "-1" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "negative_floor" + } } ] } diff --git a/scripts/check_pricing_fixtures.py b/scripts/check_pricing_fixtures.py index 6c3b097..44f3fc2 100644 --- a/scripts/check_pricing_fixtures.py +++ b/scripts/check_pricing_fixtures.py @@ -99,7 +99,8 @@ def resolve(case): require(rate > 0, "nonpositive_rate") floor = number(row.get("floor", "0")) require(floor >= 0, "negative_floor") - require(rate >= floor, "below_floor") + # A posted automatic-resolution floor cannot veto explicit acceptance. + # This arithmetic adapter does not choose or evaluate floor semantics. rate_per = 1000 if method in ("CPM", "CPMV") else 1 if case["basis"] == "spend": total = amount @@ -126,7 +127,7 @@ def resolve(case): actual = units require(actual >= value if name.startswith("min_") else actual <= value, "outside_" + name) - return {"pricing_id": chosen, "currency": row["currency"], + return {"pricing_id": chosen, "currency": row["currency"], "floor_check": "not_checked", **{key: str(value) for key, value in { "agreed_rate": rate, "flat_charges": flat, "media_spend": media, "seller_total": total, "units": units}.items()}} From 8fde92cefc82e806c71889fe0468032f6943d314 Mon Sep 17 00:00:00 2001 From: Aleksander Sekowski Date: Sat, 24 Oct 2026 11:37:06 -0700 Subject: [PATCH 17/47] Close inspection URL and notification-observation gaps --- .github/workflows/validate.yml | 4 ++ docs/creative-inspection-fixtures.md | 40 ++++++++++++- scripts/check_creative_fixtures.py | 73 ++++++++++++++++++++++- scripts/check_creative_preview.mjs | 25 ++++++-- scripts/check_preview_observations.mjs | 28 +++++++++ scripts/creative_preview_observations.mjs | 9 +++ scripts/inspect_creative_fixture.py | 72 ++++++++++++++++++---- 7 files changed, 230 insertions(+), 21 deletions(-) create mode 100644 scripts/check_preview_observations.mjs create mode 100644 scripts/creative_preview_observations.mjs diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 5a32d10..4b450dd 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -77,3 +77,7 @@ jobs: with: python-version: "3.12" - run: python3 scripts/check_creative_fixtures.py + - uses: actions/setup-node@v4 + with: + node-version: "22" + - run: node scripts/check_preview_observations.mjs diff --git a/docs/creative-inspection-fixtures.md b/docs/creative-inspection-fixtures.md index b532882..b75c288 100644 --- a/docs/creative-inspection-fixtures.md +++ b/docs/creative-inspection-fixtures.md @@ -23,13 +23,30 @@ every request, including requests to synthetic notification traps. Nothing loads a campaign URL. The two VAST templates use fixture substitution tokens that the runner replaces with the server's loopback address. -The reference inspector retrieves only exact document URLs listed by the test -harness. It follows a single-ad wrapper chain, hashes retrieved bytes and +The reference inspector retrieves only document request identities listed by +the test harness. It follows a single-ad wrapper chain, hashes retrieved bytes and parses XML. It does not execute code, fetch media, probe event URLs, expand macros, use proxy environment variables, or automatically follow redirects. An HTTP redirect can itself target an event endpoint. It must not become an unchecked route around the retrieval policy. +The allowlist, event guard and cycle detection share one HTTP URL identity +function. It removes fragments, normalizes scheme/host spelling, IPv6 spelling +and default ports, and supplies `/` for an empty path. The transport sends that +same normalized URL. Path spelling, query order, percent encodings and an empty +query delimiter are preserved. A receipt retains both the supplied document URL +and the actual request URL. Unsupported schemes, credentials, control characters +and non-ASCII URLs are blocked in this deliberately limited fixture profile. + +The event-role set includes linear, companion, non-linear and icon tracking, +viewability, verification and error URIs. Clickthrough destinations are also +excluded from wrapper retrieval. Regression cases deliberately allowlist targets +that are also declared as events, so an absent allowlist entry cannot hide a +broken event guard. Fragment aliases are tested on both sides of that comparison. +This does not infer server-side URL aliases, custom extension semantics or +resource intent. The caller must establish that its initial resource allowlist +is appropriate. It is not a production fetch policy. + Successful wrapper traversal reaches the inline document with no requests to its impression, playback, click, viewability, verification or error endpoints. Failure cases include cycles, missing documents, malformed XML, a redirect to @@ -53,6 +70,7 @@ directory. The directory must already exist. The profile is removed on exit. ``` node scripts/check_creative_preview.mjs node scripts/check_creative_preview.mjs --json +node scripts/check_preview_observations.mjs ``` The browser runs synthetic creative that attempts a GET, HEAD, POST, beacon, @@ -64,6 +82,24 @@ at that trap. A receipt includes the browser version, attempted operation, server observations, blocked requests and a 500 ms observation interval after the synchronous attempt. +The assertion checks the entire notification-trap log for that case, including +unexpected paths and methods. The receipt preserves every observation and its +full URL. Control cases must reach only their expected notification endpoint; +isolated cases must produce no notification at all. The assertion regression +tests run without a browser and are included in CI. + +An integration negative control checks that an unexpected notification cannot +disappear from the result: + +``` +node scripts/check_creative_preview.mjs --json --inject-notification-canary +``` + +That command intentionally exits with status 1. The test driver injects one +loopback event at an unexpected path in `fetch-get/isolated`; that case must +fail and its receipt must contain the event. This is a check of the assertion, +not a browser escape or a live campaign notification. + The isolated preview combines a sandboxed frame, a restrictive Content Security Policy, browser request interception and an independent HTTP proxy that permits only the exact preview-document request. Proxy bypass for diff --git a/scripts/check_creative_fixtures.py b/scripts/check_creative_fixtures.py index 24d440e..0bd8bf8 100644 --- a/scripts/check_creative_fixtures.py +++ b/scripts/check_creative_fixtures.py @@ -8,7 +8,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from inspect_creative_fixture import FixtureTransport, checks_satisfied, inspect +from inspect_creative_fixture import FixtureTransport, Unchecked, checks_satisfied, inspect, request_url ROOT = Path(__file__).resolve().parents[1] @@ -87,13 +87,56 @@ def wrap(target): ("event-also-used-as-wrapper-target", "/dual-role", "wrapper_resolution", "not_checked", ["/dual-role"], 5), ("minimal-inline-is-not-schema-validation", "/inline-minimal", "vast_schema_conformance", "not_checked", ["/inline-minimal"], 5), ] + dual_allowed = {"/dual-role": {base + "/events/wrapper-impression"}} + expected_event_guard = set() + + def add_guard_case(name, xml, target): + path = "/" + name + routes[path] = (200, xml.encode()) + allowed.add(base + path) + dual_allowed[path] = {target} + expected_event_guard.add(path) + definitions.append((name, path, "wrapper_resolution", "not_checked", [path], 5)) + + event = base + "/events/wrapper-impression" + add_guard_case("fragment-on-wrapper-target", wrapper.replace("{{TARGET}}", event + "#inspection"), event + "#inspection") + add_guard_case("fragment-on-event-url", wrapper.replace(event, event + "#measurement").replace("{{TARGET}}", event), event) + add_guard_case("scheme-case-event-alias", wrapper.replace("{{TARGET}}", event.replace("http:", "HTTP:")), event.replace("http:", "HTTP:")) + routes["/cycle-fragment"] = (200, wrap("/cycle-fragment#again")) + allowed.update({base + "/cycle-fragment", base + "/cycle-fragment#again"}) + definitions.append(("fragment-alias-cycle", "/cycle-fragment", "wrapper_resolution", "failed", ["/cycle-fragment"], 5)) + + # Fixed VAST roles, intentionally independent of the inspector's role set. + # Each target is allowlisted to prove the semantic guard, not a missing + # allowlist entry, prevents the request. + tracking = base + "/events/shared" + routes["/events/shared"] = (200, inline.encode()) + resource = f'' + for role in ("CompanionClickTracking", "CompanionClickThrough", + "NonLinearClickTracking", "NonLinearClickThrough", + "IconViewTracking", "IconClickTracking", "IconClickThrough"): + tag = f'<{role}>' + xml = wrapper.replace("{{TARGET}}", tracking) + if role.startswith("Companion"): + extra = f'{resource}{tag}' + xml = xml.replace("", extra + "") + elif role.startswith("NonLinear"): + extra = f'{resource}{tag}' + xml = xml.replace("", extra + "") + else: + if role != "IconViewTracking": + tag = "" + tag + "" + extra = f'{resource}{tag}' + xml = xml.replace("", extra + "") + add_guard_case("event-role-" + role, xml, tracking) + results = [] try: for name, path, check, outcome, expected_requests, limit in definitions: requests.clear() # Dual-role case deliberately allows the URL to prove the semantic # event guard stops it before the transport allowlist is consulted. - permitted = allowed | ({base + "/events/wrapper-impression"} if path == "/dual-role" else set()) + permitted = allowed | dual_allowed.get(path, set()) receipt = inspect(base + path, FixtureTransport(permitted), max_documents=limit) matches = [item for item in receipt["checks"] if item["check"] == check] notifications = [item for item in requests if item["path"].startswith("/events/")] @@ -105,6 +148,9 @@ def wrap(target): if path == "/wrapper" and limit == 5: passed = passed and checks_satisfied(receipt, ["document_retrieval", "xml_well_formedness", "wrapper_resolution"]) passed = passed and len(receipt["identified_event_urls"]) == 13 + if path in expected_event_guard: + passed = passed and matches[-1]["reason"] == "target is also identified as an event URL" + passed = passed and not checks_satisfied(receipt, ["document_retrieval", "xml_well_formedness", "wrapper_resolution"]) results.append({"id": name, "passed": bool(passed), "expected_check": check, "expected_outcome": outcome, "server_requests": requests.copy(), "receipt": receipt}) # A positive control proves the trap records notifications, including HEAD. @@ -120,7 +166,28 @@ def wrap(target): results.append({"id": "notification-trap-positive-control", "passed": requests == [ {"method": "GET", "path": "/events/control"}, {"method": "HEAD", "path": "/events/control"}], "server_requests": requests.copy()}) - return {"profile": "creative-inspection-discussion-1", "passed": sum(item["passed"] for item in results), + url_cases = [ + ("url-fragment-removed", "http://example.test/events/x#preview", "http://example.test/events/x"), + ("url-http-authority-normalized", "HTTP://EXAMPLE.TEST:80/events/x", "http://example.test/events/x"), + ("url-https-default-port", "https://EXAMPLE.TEST:443/events/x", "https://example.test/events/x"), + ("url-empty-path", "http://example.test", "http://example.test/"), + ("url-ipv6-normalized", "http://[0:0:0:0:0:0:0:1]:80/x", "http://[::1]/x"), + ("url-query-preserved", "http://example.test/x?signature=%7e&b=2&a=1#preview", "http://example.test/x?signature=%7e&b=2&a=1"), + ("url-empty-query-preserved", "http://example.test/x?#preview", "http://example.test/x?"), + ] + for name, value, expected in url_cases: + actual = request_url(value) + results.append({"id": name, "passed": actual == expected, "input": value, "expected": expected, "actual": actual}) + for name, value in (("url-credentials-blocked", "http://user:password@example.test/x"), + ("url-non-http-blocked", "file:///events/x"), + ("url-control-character-blocked", "http://example.test/\nx")): + try: + request_url(value) + blocked = False + except Unchecked: + blocked = True + results.append({"id": name, "passed": blocked, "expected": "not_checked"}) + return {"profile": "creative-inspection-discussion-2", "passed": sum(item["passed"] for item in results), "total": len(results), "cases": results} finally: server.shutdown() diff --git a/scripts/check_creative_preview.mjs b/scripts/check_creative_preview.mjs index c4dbb14..e650814 100644 --- a/scripts/check_creative_preview.mjs +++ b/scripts/check_creative_preview.mjs @@ -7,6 +7,7 @@ import { mkdtemp, readFile, rm, access } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { once } from 'node:events'; +import { assessNotifications } from './creative_preview_observations.mjs'; const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); const attempts = { @@ -25,6 +26,7 @@ const attempts = { websocket: `const x = new WebSocket(url.replace('http:', 'ws:')); x.onerror = () => {}`, }; const jsonMode = process.argv.includes('--json'); +const injectCanary = process.argv.includes('--inject-notification-canary'); const csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"; const escapeAttribute = value => value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<'); let base; @@ -99,7 +101,7 @@ const server = createServer((request, response) => { response.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' }); response.end(html); } else if (url.pathname.startsWith('/events/')) { - received.push({ method: request.method, path: url.pathname }); + received.push({ method: request.method, path: url.pathname, url: url.href }); response.writeHead(200, { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' }); response.end('synthetic notification trap'); } else { @@ -107,7 +109,8 @@ const server = createServer((request, response) => { } }); server.on('upgrade', (request, socket) => { - received.push({ method: 'UPGRADE', path: new URL(request.url, base).pathname }); + const url = new URL(request.url, base); + received.push({ method: 'UPGRADE', path: url.pathname, url: url.href }); socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); }); @@ -204,7 +207,7 @@ try { }); await cdp.call('Page.enable'); await cdp.call('Runtime.enable'); if (mode === 'isolated') await guard(); - received.length = 0; + const observationStart = received.length; await cdp.call('Page.navigate', {url: previewUrl}); let messages = []; for (let i = 0; i < 100; i++) { @@ -219,9 +222,18 @@ try { await Promise.all([...pendingEvents]); const state = await cdp.call('Runtime.evaluate', {expression: 'window.fixtureMessages || []', returnByValue: true}); messages = state.result.value || []; - const notifications = received.filter(request => request.path === '/events/' + id); + // Negative control for the assertion itself, sent by this test driver. + // A run with this flag must fail and retain the unexpected event. + if (injectCanary && mode === 'isolated' && id === 'fetch-get') { + await fetch(base + '/events/unexpected-audit-canary'); + } + const observation = assessNotifications(received.slice(observationStart), { + isolated, expectedPath: '/events/' + id, expectedMethod, + }); + const notifications = observation.notifications; const attempted = messages.some(message => message.attempted === id); - const passed = attempted && protocolErrors.length === 0 && (isolated ? notifications.length === 0 : notifications.some(request => request.method === expectedMethod)); + const unexpectedScriptError = messages.some(message => message.error && message.error !== 'SecurityError'); + const passed = attempted && !unexpectedScriptError && protocolErrors.length === 0 && observation.passed; cases.push({id: id + '/' + mode, passed, attempted, expected_method: expectedMethod, server_notifications: notifications, blocked_requests: blocked, network_gate_blocks: gateBlocked.slice(), messages, @@ -230,7 +242,8 @@ try { cdp.close(); cdp = null; } } - const report = {profile: 'synthetic-browser-inspection-1', browser: browser.Browser, + const report = {profile: 'synthetic-browser-inspection-2', browser: browser.Browser, + assertion_canary_injected: injectCanary, tested_at: new Date().toISOString(), passed: cases.filter(item => item.passed).length, total: cases.length, cases}; if (jsonMode) console.log(JSON.stringify(report, null, 2)); diff --git a/scripts/check_preview_observations.mjs b/scripts/check_preview_observations.mjs new file mode 100644 index 0000000..ea39205 --- /dev/null +++ b/scripts/check_preview_observations.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { assessNotifications } from './creative_preview_observations.mjs'; + +const expectedPath = '/events/fetch-get'; +const expectedMethod = 'GET'; +const cases = [ + {id:'empty-isolated-log', isolated:true, observations:[], expected:true}, + {id:'expected-event-is-still-forbidden', isolated:true, observations:[{method:'GET',path:expectedPath}], expected:false}, + {id:'unexpected-event-path-is-forbidden', isolated:true, observations:[{method:'GET',path:'/events/unexpected-audit-canary'}], expected:false}, + {id:'unexpected-event-method-is-forbidden', isolated:true, observations:[{method:'HEAD',path:'/events/unexpected'}], expected:false}, + {id:'mixed-events-are-all-preserved', isolated:true, observations:[{method:'GET',path:expectedPath},{method:'POST',path:'/events/other'}], expected:false}, + {id:'control-must-reach-trap', isolated:false, observations:[], expected:false}, + {id:'expected-control-event', isolated:false, observations:[{method:'GET',path:expectedPath}], expected:true}, + {id:'wrong-control-path', isolated:false, observations:[{method:'GET',path:'/events/other'}], expected:false}, + {id:'wrong-control-method', isolated:false, observations:[{method:'HEAD',path:expectedPath}], expected:false}, + {id:'control-with-extra-notification', isolated:false, observations:[{method:'GET',path:expectedPath},{method:'GET',path:'/events/other'}], expected:false}, +]; +const results = cases.map(vector => { + const result = assessNotifications(vector.observations, {isolated:vector.isolated, expectedPath, expectedMethod}); + // The receipt must retain the complete observations, even when they fail. + assert.deepEqual(result.notifications, vector.observations); + assert.notEqual(result.notifications, vector.observations); + return {id:vector.id, passed:result.passed === vector.expected, expected_check_result:vector.expected, actual:result}; +}); +const report = {profile:'preview-notification-assertions-1', passed:results.filter(x=>x.passed).length, total:results.length, cases:results}; +if(process.argv.includes('--json')) console.log(JSON.stringify(report,null,2)); +else {for(const item of results) console.log((item.passed?'PASS ':'FAIL ')+item.id);console.log(`${report.passed}/${report.total} notification assertions pass`);} +if(report.passed !== report.total) process.exitCode=1; diff --git a/scripts/creative_preview_observations.mjs b/scripts/creative_preview_observations.mjs new file mode 100644 index 0000000..76c2880 --- /dev/null +++ b/scripts/creative_preview_observations.mjs @@ -0,0 +1,9 @@ +// Shared assertion for the synthetic notification trap, not an egress filter. +export function assessNotifications(observations, {isolated, expectedPath, expectedMethod}) { + const notifications = observations.map(observation => ({...observation})); + const expected = observation => observation.path === expectedPath && observation.method === expectedMethod; + return { + passed: isolated ? notifications.length === 0 : notifications.length > 0 && notifications.every(expected), + notifications, + }; +} diff --git a/scripts/inspect_creative_fixture.py b/scripts/inspect_creative_fixture.py index 941dfb5..0c0780b 100644 --- a/scripts/inspect_creative_fixture.py +++ b/scripts/inspect_creative_fixture.py @@ -5,19 +5,54 @@ """ import hashlib +import ipaddress import urllib.error import urllib.request +from urllib.parse import urlsplit, urlunsplit import xml.etree.ElementTree as ET from datetime import datetime, timezone EVENT_ELEMENTS = {"Impression", "Error", "Tracking", "ClickTracking", "ClickThrough", - "CustomClick", "Viewable", "NotViewable", "ViewUndetermined"} + "CustomClick", "Viewable", "NotViewable", "ViewUndetermined", + "CompanionClickTracking", "CompanionClickThrough", + "NonLinearClickTracking", "NonLinearClickThrough", + "IconViewTracking", "IconClickTracking", "IconClickThrough"} class Unchecked(Exception): """The check could not run within the fixture's retrieval policy.""" +def request_url(url): + """Canonicalize the HTTP request identity used by this fixture transport. + + Fragments never reach the server. Preserve path/query spelling because + application routing and signatures can depend on it. This cannot identify + arbitrary server-side aliases, redirects or the meaning of a resource. + """ + if not isinstance(url, str) or not url.isascii() or any(ord(c) <= 32 or ord(c) == 127 for c in url): + raise Unchecked("URL is outside the ASCII fixture profile") + try: + parts = urlsplit(url) + if parts.scheme not in ("http", "https") or not parts.hostname or parts.username is not None or parts.password is not None: + raise ValueError("unsupported authority") + host = parts.hostname.lower() + if ":" in host: + host = "[" + ipaddress.IPv6Address(host).compressed + "]" + port = parts.port + if port is not None and not 1 <= port <= 65535: + raise ValueError("invalid port") + authority = host + if port is not None and port != (80 if parts.scheme == "http" else 443): + authority += ":" + str(port) + normalized = urlunsplit((parts.scheme, authority, parts.path or "/", parts.query, "")) + if not parts.query and "?" in url.split("#", 1)[0]: + normalized += "?" + return normalized + except ValueError as error: + raise Unchecked("URL is outside the HTTP fixture profile") from error + + class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None @@ -25,14 +60,15 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): class FixtureTransport: def __init__(self, documents, max_bytes=65536): - # Explicit exact URLs come from the local fixture server, not the XML. + # Explicit document URLs come from the local fixture server, not the XML. # Callers must establish each URL is a document resource, not an event. - self.documents = frozenset(documents) + self.documents = frozenset(request_url(url) for url in documents) self.max_bytes = max_bytes self.requests = [] self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) def get(self, url): + url = request_url(url) if url not in self.documents: raise Unchecked("document URL is outside the fixture allowlist") self.requests.append({"method": "GET", "url": url}) @@ -48,6 +84,7 @@ def get(self, url): def inspect(root_url, transport, *, asset_id="fixture-asset", max_documents=5): checks, documents, event_urls, visited = [], [], set(), set() + event_requests = set() def record(name, outcome, reason, url=None): check = {"check": name, "outcome": outcome, "reason": reason} @@ -57,13 +94,19 @@ def record(name, outcome, reason, url=None): url = root_url for _ in range(max_documents): - if url in event_urls: + try: + identity = request_url(url) + except Unchecked as error: + record("document_retrieval", "not_checked", str(error), url) + record("wrapper_resolution", "not_checked", "target URL is unsupported", url) + break + if identity in event_requests: record("wrapper_resolution", "not_checked", "target is also identified as an event URL", url) break - if url in visited: + if identity in visited: record("wrapper_resolution", "failed", "wrapper cycle in retrieved documents", url) break - visited.add(url) + visited.add(identity) try: data = transport.get(url) except Unchecked as error: @@ -71,7 +114,8 @@ def record(name, outcome, reason, url=None): record("wrapper_resolution", "not_checked", "a required document was not retrieved", url) break record("document_retrieval", "passed", "retrieved the requested document bytes", url) - documents.append({"url": url, "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}) + documents.append({"url": url, "request_url": identity, + "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}) try: xml = data.decode("utf-8") except UnicodeDecodeError: @@ -95,7 +139,14 @@ def record(name, outcome, reason, url=None): break for element in root.iter(): if element.tag in EVENT_ELEMENTS and element.text and element.text.strip(): - event_urls.add(element.text.strip()) + event_url = element.text.strip() + event_urls.add(event_url) + try: + event_requests.add(request_url(event_url)) + except Unchecked: + # Unsupported event URIs are never retrieval candidates. + # This does not validate them or expand their macros. + pass ads = root.findall("Ad") if len(ads) != 1: record("wrapper_resolution", "not_checked", "fixture profile requires exactly one Ad", url) @@ -125,11 +176,12 @@ def record(name, outcome, reason, url=None): ("seller_approval", "no seller decision was supplied"), ): record(name, "not_checked", reason) - return {"receipt_profile": "creative-inspection-discussion-1", "asset_id": asset_id, + return {"receipt_profile": "creative-inspection-discussion-2", "asset_id": asset_id, "inspected_at": datetime.now(timezone.utc).isoformat(), "context": {"root_url": root_url, "scope": "single-ad UTF-8 fixture document inspection", "max_documents": max_documents, "max_document_bytes": transport.max_bytes, - "redirect_policy": "blocked", "macro_expansion": "not_performed"}, + "redirect_policy": "blocked", "macro_expansion": "not_performed", + "url_identity": "HTTP authority and default port normalized; fragments removed; path and query preserved"}, "documents": documents, "identified_event_urls": sorted(event_urls), "requests": transport.requests, "checks": checks, "readiness": "not_assessed"} From 49ad2e29af93db6e0fefd91db53bbda0b30281b1 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sat, 24 Oct 2026 23:23:06 -0400 Subject: [PATCH 18/47] Define channel and environments in AdCOM terms --- examples/line-item-audio.yaml | 3 +- spec/CHANGELOG.md | 7 +++++ spec/openproposal-3.0.md | 56 +++++++++++++++++++++++++++++++---- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/examples/line-item-audio.yaml b/examples/line-item-audio.yaml index 4ca2f21..cae02f2 100644 --- a/examples/line-item-audio.yaml +++ b/examples/line-item-audio.yaml @@ -2,7 +2,8 @@ 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 } } } diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 1dfacef..c15b084 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,6 +5,13 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] +- § 2.1, § 5.1, § 5.2 — `channel` and `environments` are defined in AdCOM 1.0 + terms. Each impression has one `channel`, the first match in § 5.1.1, and + each environment is a set of distribution channels and device types. + `audio_streaming` is removed, and the Summary Representation carries + `environments.available[]` + ([#90](https://github.com/IABTechLab/OpenProposal/issues/90)). + ## [3.0-draft-1] — 2026-09-22 Initial draft published for public comment. Ported from the working-group diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 6940958..5cf875e 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -76,9 +76,9 @@ Every line item has two representations. sellers' shelves. A catalog response MAY return it. It MUST contain, per line item: -`line_item_id` · `channel` · `content_detail` · property names · -`formats.included[]` · `pricing.cost_method` · `pricing.gross_rate` · -`min_spend` · `addressable_scale` · `committed_metrics[]` · +`line_item_id` · `channel` · `environments.available[]` · `content_detail` · +property names · `formats.included[]` · `pricing.cost_method` · +`pricing.gross_rate` · `min_spend` · `addressable_scale` · `committed_metrics[]` · `transaction_mechanism` plus, from the parent proposal: `proposal_id` · `seller_name` · `description` · @@ -216,7 +216,37 @@ channel. | :--- | :--- | :--- | :--- | | `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 | +| `channel` | Enum | immutable | `display` \| `video` \| `audio` \| `ctv` \| `dooh` \| `native`, each defined 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). + +| `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`) | + +Device types are values of AdCOM 1.0 List: Device Types, each written as its +name followed by its code. + +- A line item MUST serve only impressions of its `channel`. Placement type, + such as interstitial or rewarded, does not change the channel. +- A consumer MUST skip a line item whose `channel` it does not recognise, and + MUST NOT reject the proposal because of it. +- Linear TV, including addressable linear, has no `channel` value in 3.0. ### 5.2 Inventory composition @@ -224,7 +254,23 @@ channel. | :--- | :--- | :--- | :--- | | `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` | +| `environments` | Object | selectable | `available[]` from `web_desktop` \| `web_mobile` \| `app_mobile` \| `ctv` \| `dooh`, each defined in 5.2.1 | + +#### 5.2.1 environments + +Each environment value is a set of distribution channels and device types, in +AdCOM 1.0 terms. Device types are as in 5.1.1. + +| Value | 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 an impression that the line +item's `channel` admits (5.1.1). ### 5.3 Audience reach From 0bc3f7e05240fab93e6dcf9ee180df264602d35f Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sat, 24 Oct 2026 23:24:08 -0400 Subject: [PATCH 19/47] Add placement to say what each impression is --- examples/line-item-ctv.yaml | 1 + examples/line-item-rewarded-video.yaml | 37 +++++++++++++++++ spec/CHANGELOG.md | 5 +++ spec/openproposal-3.0.md | 56 ++++++++++++++++++++++++-- 4 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 examples/line-item-rewarded-video.yaml diff --git a/examples/line-item-ctv.yaml b/examples/line-item-ctv.yaml index fda3994..55f289e 100644 --- a/examples/line-item-ctv.yaml +++ b/examples/line-item-ctv.yaml @@ -4,6 +4,7 @@ status: published channel: ctv properties: { available: [{ id: "weather-app" }], included: ["weather-app"] } environments: { available: ["ctv"], included: ["ctv"] } +placement: { plcmt: 1, skip: 0 } # Instream (1), not skippable (§ 5.2.1) formats: { available: ["ctv/instream-15s", "ctv/instream-30s"], included: ["ctv/instream-15s"], pricing: { "ctv/instream-30s": { delta_cpm: 8.00 } } } diff --git a/examples/line-item-rewarded-video.yaml b/examples/line-item-rewarded-video.yaml new file mode 100644 index 0000000..7fbc38f --- /dev/null +++ b/examples/line-item-rewarded-video.yaml @@ -0,0 +1,37 @@ +# OpenProposal 3.0 — Example: Rewarded video line item (spec § 6.5) +# Illustrative only. In-app rewarded video across a pack of puzzle games. +line_item_id: "puzzle-games-rewarded" +status: published +channel: video +properties: { available: [{ id: "puzzle-pack", name: "Puzzle game pack" }], + included: ["puzzle-pack"] } +environments: { available: ["app_mobile"], included: ["app_mobile"] } +placement: # every impression of this line item (§ 5.2.1) + reward: 1 + instl: 1 + plcmt: 3 # Interstitial (3) + skip: 0 # watched to the end for the reward + playmethod: [3] # Initiates on Click with Sound On (3) + api: [6, 7] # MRAID 3.0 (6), OMID 1.0 (7) +formats: { available: ["video/30s"], included: ["video/30s"] } +creative_delivery: { method: buyer_hosted, protocol: vast_4_2, secure_required: true } + +frequency_cap: { max: { min: 1, max: 3 }, period: "day", basis: device } + +pricing: + - { cost_method: CPM, gross_rate: 22.00, floor: 19.50, + price_valid_until: "2026-12-01T23:59:59Z", currency: USD } + +commitment_bounds: { min_budget: 10000.00 } + +committed_metrics: + - { metric: completion_rate, value: 0.95, basis: mrc } + - { metric: viewable_rate, value: 0.90, basis: mrc } +measurement_source: # third-party vendors run through OMID 1.0 (7) + available: [{ vendor: "IAS", attribution_window: "30d" }, + { vendor: "DoubleVerify", attribution_window: "30d" }] + included: ["IAS"] + +buying_route: SSP_PLATFORM +transaction_mechanism: { available: ["PREFERRED_DEAL"], included: ["PREFERRED_DEAL"] } +serving_mode: client_side diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 1dfacef..c544585 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,6 +5,11 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] +- § 2.1, § 5.2 — `placement` on the line item states what every impression + is, in AdCOM 1.0 and OpenRTB 2.6 terms: rewarded, interstitial, video + placement subtype, skippability, playback, API frameworks and refresh + ([#89](https://github.com/IABTechLab/OpenProposal/issues/89)). + ## [3.0-draft-1] — 2026-09-22 Initial draft published for public comment. Ported from the working-group diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 6940958..d84fded 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -79,7 +79,8 @@ item: `line_item_id` · `channel` · `content_detail` · property names · `formats.included[]` · `pricing.cost_method` · `pricing.gross_rate` · `min_spend` · `addressable_scale` · `committed_metrics[]` · -`transaction_mechanism` +`transaction_mechanism`, and, when present, `placement.reward` · +`placement.instl` · `placement.plcmt` · `placement.skip` plus, from the parent proposal: `proposal_id` · `seller_name` · `description` · `valid_from` · `valid_until`. @@ -225,6 +226,46 @@ channel. | `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` | +| `placement` | Object | seller-set | What every impression of this line item is: rewarded, interstitial, video placement subtype, skippability, playback, API frameworks and refresh. `plcmt` is REQUIRED for video ads. See 5.2.1 | + +#### 5.2.1 placement + +Keys follow [AdCOM 1.0 `Placement`](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#object_placement) and its +[`DisplayPlacement`](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#object_displayplacement), +[`VideoPlacement`](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#object_videoplacement) and +[`AudioPlacement`](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#object_audioplacement) subtypes, and the matching +[OpenRTB 2.6 `Imp`](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/1debba8001f40eb14597102dec3205afddf811d4/2.6.md#objectimp) attributes. Codes are values of +[List: Plcmt Subtypes - Video](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#list_plcmtsubtypesvideo), +[List: Playback Methods](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#list_playbackmethods) and +[List: API Frameworks](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#list_apiframeworks), each written as its name +followed by its code. + +| Key | Required | Meaning | AdCOM 1.0, OpenRTB 2.6 | +| :--- | :--- | :--- | :--- | +| `reward` | OPTIONAL | `0` (no) \| `1` (yes). The user receives a reward for viewing the ad | `Placement.reward`, `imp.rwdd` | +| `instl` | OPTIONAL | `0` (no) \| `1` (yes). The ad is interstitial or full screen | `DisplayPlacement.instl`, `imp.instl` | +| `plcmt` | REQUIRED for video ads (`channel` `video`, or video on `channel` `ctv`); otherwise OPTIONAL | One value from List: Plcmt Subtypes - Video: Instream (1), Accompanying Content (2), Interstitial (3), No Content/Standalone (4), Pause (5), Screensaver (6), Overlay (7), Squeezeback (8), In-scene (9) | `VideoPlacement.ptype`, `video.plcmt` | +| `skip` | OPTIONAL | Video or audio. `0` (no) \| `1` (yes). The placement lets the user skip the ad | `VideoPlacement.skip`, `AudioPlacement.skip`, `video.skip` | +| `skipafter` | OPTIONAL | When `skip` is `1`: seconds the ad plays before the placement enables skipping | `VideoPlacement.skipafter`, `AudioPlacement.skipafter`, `video.skipafter` | +| `playmethod[]` | OPTIONAL | Video or audio. Values from List: Playback Methods in use, such as Initiates without any specific user interaction with Sound On (1), Initiates without any specific user interaction Sound Off by Default (2), Initiates on Click with Sound On (3) and Continuous Playback (7) | `VideoPlacement.playmethod`, `AudioPlacement.playmethod`, `video.playbackmethod` | +| `api[]` | OPTIONAL | Values from List: API Frameworks that every impression supports, such as MRAID 3.0 (6), OMID 1.0 (7) and SIMID 1.1 (9) | `DisplayPlacement.api`, `VideoPlacement.api`, `AudioPlacement.api`, `banner.api`, `video.api`, `audio.api` | +| `refresh` | OPTIONAL | `{ min_interval_s }`: minimum seconds a creative renders before the slot refreshes | OpenRTB 2.6 only: `imp.refresh.refsettings[].minint` | + +`plcmt` takes its OpenRTB 2.6 name. AdCOM 1.0 carries the same list as +`VideoPlacement.ptype`. OpenRTB 2.6 `Audio` has no `skip`, `skipafter` or +`playbackmethod`, so for audio those keys map to AdCOM 1.0 `AudioPlacement` +only. + +- A key that is present holds for every impression of the line item. An absent + key does not constrain it: a line item that omits `instl` can deliver to + interstitial and non-interstitial impressions alike. AdCOM 1.0 reads an + absent `reward` or `instl` as `0`; here it means either value. +- The seller MUST NOT deliver the line item to an impression that does not + match a key that is present. +- Unless `api[]` includes OMID 1.0 (7), a line item whose `committed_metrics[]` + includes `viewable_rate` and whose `environments.available[]` includes + `app_mobile` or `ctv` SHOULD NOT offer a `measurement_source` vendor other + than the seller. ### 5.3 Audience reach @@ -375,6 +416,12 @@ See [`examples/line-item-ctv.yaml`](../examples/line-item-ctv.yaml). See [`examples/line-item-audio.yaml`](../examples/line-item-audio.yaml). +### 6.5 Rewarded video line item + +Shows `placement` on in-app rewarded video, with OMID support that lets a +buyer select a third-party `measurement_source`. See +[`examples/line-item-rewarded-video.yaml`](../examples/line-item-rewarded-video.yaml). + --- ## 7. Why this split @@ -454,6 +501,7 @@ body is normative.** | `properties` | 5.2 Inventory composition | Object | selectable | | `content_detail` | 5.2 Inventory composition | Object | seller-set | | `environments` | 5.2 Inventory composition | Object | selectable | +| `placement` | 5.2 Inventory composition | Object | seller-set | | `audiences` | 5.3 Audience reach | Object | selectable | | `addressable_scale` | 5.3 Audience reach | Integer | derived | | `activation_route` | 5.3 Audience reach | Object | selectable | @@ -512,7 +560,7 @@ 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[]` | +| seller-set | 30 | Seller sets and may revise while `draft` or `published`. | `valid_from` · `valid_until`, `description`, `specifications`, `best_for[]`, `not_suitable_for[]`, `seasonality_notes`, `content_detail`, `placement`, `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[]` | @@ -522,8 +570,8 @@ body is normative.** ### 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`, +Of 72 fields, **23 are touched by the buyer** — 12 `selectable`, 5 `settable`, 3 +`supplied` and 3 `requestable`. The remaining 49 are `seller-set`, `immutable`, `derived` or `mixed`. Only the 3 `requestable` fields require a round trip. Everything else the buyer From 0125d5549c4cc1790bd3ab847e8d5826b66de82d Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 25 Oct 2026 00:20:18 -0400 Subject: [PATCH 20/47] Clarify channel and environment matching rules Read the media condition from the ad served when an impression accepts several media, name the seller as the party that serves, place linear TV outside the table, and state environments as (distribution channel, device type) pairs. Insert environments.available[] into the summary list without rewrapping it, shorten the changelog entry and drop audio_streaming from OQ-7 in docs/open-questions.md. Refs #90 --- docs/open-questions.md | 4 ++-- spec/CHANGELOG.md | 9 ++++----- spec/openproposal-3.0.md | 30 +++++++++++++++++++----------- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/docs/open-questions.md b/docs/open-questions.md index d8d32ba..2adbdf2 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -96,8 +96,8 @@ property). **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/spec/CHANGELOG.md b/spec/CHANGELOG.md index c15b084..8fb94fa 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,11 +5,10 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] -- § 2.1, § 5.1, § 5.2 — `channel` and `environments` are defined in AdCOM 1.0 - terms. Each impression has one `channel`, the first match in § 5.1.1, and - each environment is a set of distribution channels and device types. - `audio_streaming` is removed, and the Summary Representation carries - `environments.available[]` +- § 2.1, § 5.1, § 5.2 — `channel` and `environments` defined in AdCOM 1.0 + terms, with a first-match channel table, a skip rule for unknown channels + and a channel and environment consistency rule. `audio_streaming` removed; + the summary carries `environments.available[]` ([#90](https://github.com/IABTechLab/OpenProposal/issues/90)). ## [3.0-draft-1] — 2026-09-22 diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 5cf875e..987d945 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -76,9 +76,10 @@ Every line item has two representations. sellers' shelves. A catalog response MAY return it. It MUST contain, per line item: -`line_item_id` · `channel` · `environments.available[]` · `content_detail` · -property names · `formats.included[]` · `pricing.cost_method` · -`pricing.gross_rate` · `min_spend` · `addressable_scale` · `committed_metrics[]` · +`line_item_id` · `channel` · `environments.available[]` · +`content_detail` · property names · +`formats.included[]` · `pricing.cost_method` · `pricing.gross_rate` · +`min_spend` · `addressable_scale` · `committed_metrics[]` · `transaction_mechanism` plus, from the parent proposal: `proposal_id` · `seller_name` · `description` · @@ -227,8 +228,10 @@ 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 +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 | | :--- | :--- | @@ -242,11 +245,14 @@ subtypes), and the Device types are values of AdCOM 1.0 List: Device Types, each written as its name followed by its code. -- A line item MUST serve only impressions of its `channel`. Placement type, - such as interstitial or rewarded, does not change the channel. +- 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. - A consumer MUST skip a line item whose `channel` it does not recognise, and MUST NOT reject the proposal because of it. -- Linear TV, including addressable linear, has no `channel` value in 3.0. +- Impressions in linear TV, including addressable linear, are outside this + table: they have no `channel` value in 3.0. ### 5.2 Inventory composition @@ -258,8 +264,10 @@ name followed by its code. #### 5.2.1 environments -Each environment value is a set of distribution channels and device types, in -AdCOM 1.0 terms. Device types are as in 5.1.1. +Each environment 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. | Value | Distribution channel | Device types | | :--- | :--- | :--- | @@ -269,8 +277,8 @@ AdCOM 1.0 terms. Device types are as in 5.1.1. | `ctv` | `Site` or `App` | Connected TV (3), Connected Device (6), Set Top Box (7) | | `dooh` | `Dooh` | Any | -Each `environments.available[]` value MUST match an impression that the line -item's `channel` admits (5.1.1). +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 From 81de4d827440cc42d8e027d5f88843e8da109bdd Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 25 Oct 2026 00:21:48 -0400 Subject: [PATCH 21/47] Define placement matching and value types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the summary rule out of the § 2.1 key list, drop skip from the CTV example, and mark the CHANGELOG entry as breaking. --- examples/line-item-ctv.yaml | 2 +- examples/line-item-rewarded-video.yaml | 2 +- spec/CHANGELOG.md | 3 ++- spec/openproposal-3.0.md | 24 ++++++++++++++++++------ 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/examples/line-item-ctv.yaml b/examples/line-item-ctv.yaml index 55f289e..f895b87 100644 --- a/examples/line-item-ctv.yaml +++ b/examples/line-item-ctv.yaml @@ -4,7 +4,7 @@ status: published channel: ctv properties: { available: [{ id: "weather-app" }], included: ["weather-app"] } environments: { available: ["ctv"], included: ["ctv"] } -placement: { plcmt: 1, skip: 0 } # Instream (1), not skippable (§ 5.2.1) +placement: { plcmt: 1 } # Instream (1) (§ 5.2) formats: { available: ["ctv/instream-15s", "ctv/instream-30s"], included: ["ctv/instream-15s"], pricing: { "ctv/instream-30s": { delta_cpm: 8.00 } } } diff --git a/examples/line-item-rewarded-video.yaml b/examples/line-item-rewarded-video.yaml index 7fbc38f..fde5a71 100644 --- a/examples/line-item-rewarded-video.yaml +++ b/examples/line-item-rewarded-video.yaml @@ -6,7 +6,7 @@ channel: video properties: { available: [{ id: "puzzle-pack", name: "Puzzle game pack" }], included: ["puzzle-pack"] } environments: { available: ["app_mobile"], included: ["app_mobile"] } -placement: # every impression of this line item (§ 5.2.1) +placement: # every impression of this line item (§ 5.2) reward: 1 instl: 1 plcmt: 3 # Interstitial (3) diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index c544585..66bbd75 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -7,7 +7,8 @@ issue that caused it, so the comment record and the spec history line up. - § 2.1, § 5.2 — `placement` on the line item states what every impression is, in AdCOM 1.0 and OpenRTB 2.6 terms: rewarded, interstitial, video - placement subtype, skippability, playback, API frameworks and refresh + placement subtype, skippability, playback, API frameworks and refresh. + `plcmt` is REQUIRED for video ads. Breaking ([#89](https://github.com/IABTechLab/OpenProposal/issues/89)). ## [3.0-draft-1] — 2026-09-22 diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index d84fded..59c624f 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -79,14 +79,17 @@ item: `line_item_id` · `channel` · `content_detail` · property names · `formats.included[]` · `pricing.cost_method` · `pricing.gross_rate` · `min_spend` · `addressable_scale` · `committed_metrics[]` · -`transaction_mechanism`, and, when present, `placement.reward` · -`placement.instl` · `placement.plcmt` · `placement.skip` +`transaction_mechanism` plus, from the parent proposal: `proposal_id` · `seller_name` · `description` · `valid_from` · `valid_until`. `catalog_ref` values MUST remain unresolved in a summary. +A line item that carries `placement` (§ 5.2) MUST also give, in its summary, +whichever of `placement.reward` · `placement.instl` · `placement.plcmt` · +`placement.skip` it declares. + **Full Representation** — every field, with all `catalog_ref` values resolved. A full fetch MUST return it. @@ -234,11 +237,16 @@ Keys follow [AdCOM 1.0 `Placement`](https://github.com/InteractiveAdvertisingBur [`DisplayPlacement`](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#object_displayplacement), [`VideoPlacement`](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#object_videoplacement) and [`AudioPlacement`](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#object_audioplacement) subtypes, and the matching -[OpenRTB 2.6 `Imp`](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/1debba8001f40eb14597102dec3205afddf811d4/2.6.md#objectimp) attributes. Codes are values of +[OpenRTB 2.6 `Imp`](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/1debba8001f40eb14597102dec3205afddf811d4/2.6.md#objectimp) attributes. + +Each key takes an integer, except `playmethod[]` and `api[]`, which take +arrays of integers, and `refresh`, an object whose `min_interval_s` is a +REQUIRED integer. `plcmt`, `playmethod[]` and `api[]` take any code in [List: Plcmt Subtypes - Video](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#list_plcmtsubtypesvideo), [List: Playback Methods](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#list_playbackmethods) and -[List: API Frameworks](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#list_apiframeworks), each written as its name -followed by its code. +[List: API Frameworks](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#list_apiframeworks) respectively. This +section names each code by its AdCOM name followed by the code, such as +Instream (1). | Key | Required | Meaning | AdCOM 1.0, OpenRTB 2.6 | | :--- | :--- | :--- | :--- | @@ -261,7 +269,11 @@ only. interstitial and non-interstitial impressions alike. AdCOM 1.0 reads an absent `reward` or `instl` as `0`; here it means either value. - The seller MUST NOT deliver the line item to an impression that does not - match a key that is present. + match a key that is present. An impression matches `api[]` when it supports + every listed framework, `playmethod[]` when its playback method is one of the + listed values, and `refresh` when the slot renders each creative for at least + `min_interval_s` seconds before it refreshes, or does not refresh. It matches + any other key when its value is equal. - Unless `api[]` includes OMID 1.0 (7), a line item whose `committed_metrics[]` includes `viewable_rate` and whose `environments.available[]` includes `app_mobile` or `ctv` SHOULD NOT offer a `measurement_source` vendor other From e51ee17d2c74d166dd75c3da29698de4e8a998fa Mon Sep 17 00:00:00 2001 From: Aleksander Sekowski Date: Sun, 25 Oct 2026 11:01:59 -0700 Subject: [PATCH 22/47] Clarify fee basis and allow zero-rate unit commitments --- .../pricing-commitment-arithmetic.md | 29 +- fixtures/pricing/cases.json | 374 +++++++++++++++++- scripts/check_pricing_fixtures.py | 4 +- 3 files changed, 391 insertions(+), 16 deletions(-) diff --git a/docs/proposals/pricing-commitment-arithmetic.md b/docs/proposals/pricing-commitment-arithmetic.md index 4e8ac39..772f212 100644 --- a/docs/proposals/pricing-commitment-arithmetic.md +++ b/docs/proposals/pricing-commitment-arithmetic.md @@ -36,13 +36,14 @@ entries to compute the buyer's charge. A selected option's `delta_flat` is a one-time seller charge for that line item commitment. It MUST NOT enter the unit rate or become another pricing -alternative solely to make it additive. A cost already included in the unit -rate MUST NOT also be added as a flat charge. Each selected option that is not +alternative. A seller fee MUST NOT be a `pricing[]` entry. A cost already +included in the unit rate MUST NOT also be added as a flat charge. Each selected option that is not already included contributes its charge once. -For `basis = spend`, `amount` is the total pre-tax amount payable to the seller -for this line item, including applicable seller flat charges. It excludes -amounts payable by the buyer to other parties. For a selected unit-rate method: +For `basis = spend`, `amount` is the seller total, on the same terms as +`agreed_rate`, including selected `delta_flat` charges (section 3.1). This does +not choose gross or net commission treatment, or decide the tax question in +#115. For a selected unit-rate method: ``` flat_charges = sum of applicable one-time seller charges @@ -50,21 +51,22 @@ media_spend = commitment.amount - flat_charges derived_units = media_spend / agreed_rate * rate_per ``` -`rate_per` is 1,000 for CPM and CPMV, and 1 for CPC. The agreed rate MUST be -positive. Flat charges MUST NOT exceed the spend commitment. Zero remaining -media spend yields zero informative units and still has to satisfy any +`rate_per` is 1,000 for CPM and CPMV, and 1 for CPC. For `basis = spend`, the +agreed rate MUST be positive. Flat charges MUST NOT exceed the spend commitment. +Zero remaining media spend yields zero informative units and still has to satisfy any applicable minimum-unit requirement. -For `basis = units`, the committed units remain binding: +For `basis = units`, the committed units remain binding. The agreed rate MAY +be zero for added-value units, and MUST NOT be negative: ``` media_spend = commitment.amount / rate_per * agreed_rate seller_total = media_spend + flat_charges ``` -`min_spend` and budget bounds apply to the seller total on the same pre-tax -basis. The selected entry's currency applies to rates, flat charges and budget -bounds. No currency conversion is implied. Informative unit estimates MUST NOT +`min_spend` and budget bounds apply to the seller total on the same basis as +`agreed_rate`. The selected entry's currency applies to rates, flat charges and +budget bounds. No currency conversion is implied. Informative unit estimates MUST NOT be rounded in a way that changes the committed amount or authorizes extra spend. ## Arithmetic inputs do not establish acceptance @@ -104,7 +106,8 @@ outputs avoid binary floating point rounding. `"1000/3"` is an exact informative quantity, not a proposed JSON wire type. Currency checks cover agreement and shape, not ISO-4217 membership. The caller supplies a mechanism-eligible choice. -The profile implements CPM, CPMV and CPC only. It rejects unsupported tax-inclusive +The profile implements CPM, CPMV and CPC on a tax-exclusive basis only. That +fixture limit does not decide the normative tax basis. It rejects tax-inclusive inputs, #103 print adjustments and flat credits. It cannot infer a mandatory fee that is not represented by the resolved options. General fee objects, tax amounts, invoice rounding and cost-method extensions need separate decisions. Impression diff --git a/fixtures/pricing/cases.json b/fixtures/pricing/cases.json index 94c6ed8..958ad4b 100644 --- a/fixtures/pricing/cases.json +++ b/fixtures/pricing/cases.json @@ -1,5 +1,5 @@ { - "profile": "pricing-discussion-2", + "profile": "pricing-discussion-3", "cases": [ { "id": "cpm-no-fees", @@ -1524,6 +1524,378 @@ "expected": { "error": "negative_floor" } + }, + { + "id": "units-fee-inclusive-min_spend-11000", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "bounds": { + "min_spend": "11000" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "floor_check": "not_checked", + "agreed_rate": "25", + "flat_charges": "1500", + "media_spend": "10000", + "seller_total": "11500", + "units": "400000" + } + } + }, + { + "id": "units-fee-inclusive-min_spend-11500", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "bounds": { + "min_spend": "11500" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "floor_check": "not_checked", + "agreed_rate": "25", + "flat_charges": "1500", + "media_spend": "10000", + "seller_total": "11500", + "units": "400000" + } + } + }, + { + "id": "units-fee-inclusive-min_spend-12000", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "bounds": { + "min_spend": "12000" + } + }, + "expected": { + "error": "outside_min_spend" + } + }, + { + "id": "units-fee-inclusive-min_budget-11000", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "bounds": { + "min_budget": "11000" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "floor_check": "not_checked", + "agreed_rate": "25", + "flat_charges": "1500", + "media_spend": "10000", + "seller_total": "11500", + "units": "400000" + } + } + }, + { + "id": "units-fee-inclusive-min_budget-11500", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "bounds": { + "min_budget": "11500" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "floor_check": "not_checked", + "agreed_rate": "25", + "flat_charges": "1500", + "media_spend": "10000", + "seller_total": "11500", + "units": "400000" + } + } + }, + { + "id": "units-fee-inclusive-min_budget-12000", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "bounds": { + "min_budget": "12000" + } + }, + "expected": { + "error": "outside_min_budget" + } + }, + { + "id": "zero-rate-added-value-units-fee-0", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "0", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "floor_check": "not_checked", + "agreed_rate": "0", + "flat_charges": "0", + "media_spend": "0", + "seller_total": "0", + "units": "400000" + } + } + }, + { + "id": "zero-rate-added-value-units-fee-1500", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "0", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "floor_check": "not_checked", + "agreed_rate": "0", + "flat_charges": "1500", + "media_spend": "0", + "seller_total": "1500", + "units": "400000" + } + } + }, + { + "id": "units-accepted-counter-zero", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "selection_key": "accepted-free-units", + "accepted_counter": { + "rate": "0", + "selection_key": "accepted-free-units" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "floor_check": "not_checked", + "agreed_rate": "0", + "flat_charges": "1500", + "media_spend": "0", + "seller_total": "1500", + "units": "400000" + } + } + }, + { + "id": "units-accepted-counter-negative", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "selection_key": "accepted-free-units", + "accepted_counter": { + "rate": "-1", + "selection_key": "accepted-free-units" + } + }, + "expected": { + "error": "nonpositive_rate" + } + }, + { + "id": "negative-rate-units", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "-1", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ] + }, + "expected": { + "error": "nonpositive_rate" + } } ] } diff --git a/scripts/check_pricing_fixtures.py b/scripts/check_pricing_fixtures.py index 44f3fc2..a943f76 100644 --- a/scripts/check_pricing_fixtures.py +++ b/scripts/check_pricing_fixtures.py @@ -62,7 +62,7 @@ def resolve(case): amount = number(case["amount"]) require(amount > 0, "nonpositive_amount") rate = number(row["gross_rate"]) - require(rate > 0, "nonpositive_rate") + require(rate >= 0, "nonpositive_rate") cpm_delta, flat, factors = Fraction(0), Fraction(0), [] adjustments = case["adjustments"] require(isinstance(adjustments, list), "invalid_shape") @@ -96,7 +96,7 @@ def resolve(case): require(counter["selection_key"] == case["selection_key"], "stale_counter") # The accepted counter is the final composed rate, not a new base rate. rate = number(counter["rate"]) - require(rate > 0, "nonpositive_rate") + require(rate > 0 if case["basis"] == "spend" else rate >= 0, "nonpositive_rate") floor = number(row.get("floor", "0")) require(floor >= 0, "negative_floor") # A posted automatic-resolution floor cannot veto explicit acceptance. From 395ca8357533843450083d1676b6cc6cab2c87c0 Mon Sep 17 00:00:00 2001 From: Aleksander Sekowski Date: Sun, 25 Oct 2026 11:03:04 -0700 Subject: [PATCH 23/47] Narrow creative inspection wording and separate fixture tooling --- .github/workflows/validate.yml | 14 -- docs/creative-inspection-fixtures.md | 173 -------------- examples/line-item-ctv.yaml | 3 +- fixtures/creative/inline.xml | 45 ---- fixtures/creative/wrapper.xml | 20 -- scripts/check_creative_fixtures.py | 209 ----------------- scripts/check_creative_preview.mjs | 269 ---------------------- scripts/check_preview_observations.mjs | 28 --- scripts/creative_preview_observations.mjs | 9 - scripts/inspect_creative_fixture.py | 198 ---------------- spec/CHANGELOG.md | 2 +- spec/openproposal-3.0.md | 27 +-- 12 files changed, 16 insertions(+), 981 deletions(-) delete mode 100644 docs/creative-inspection-fixtures.md delete mode 100644 fixtures/creative/inline.xml delete mode 100644 fixtures/creative/wrapper.xml delete mode 100644 scripts/check_creative_fixtures.py delete mode 100644 scripts/check_creative_preview.mjs delete mode 100644 scripts/check_preview_observations.mjs delete mode 100644 scripts/creative_preview_observations.mjs delete mode 100644 scripts/inspect_creative_fixture.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 4b450dd..1ea9da9 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -67,17 +67,3 @@ jobs: python-version: "3.12" - name: Check every field in the body appears in Appendix A run: python3 scripts/check_appendix.py - - creative-inspection: - name: Creative inspection fixtures - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - run: python3 scripts/check_creative_fixtures.py - - uses: actions/setup-node@v4 - with: - node-version: "22" - - run: node scripts/check_preview_observations.mjs diff --git a/docs/creative-inspection-fixtures.md b/docs/creative-inspection-fixtures.md deleted file mode 100644 index b75c288..0000000 --- a/docs/creative-inspection-fixtures.md +++ /dev/null @@ -1,173 +0,0 @@ -# Inspect creative without reporting live delivery - -This contribution implements the proposed wording in -[#42](https://github.com/IABTechLab/OpenProposal/issues/42) and supplies synthetic -fixtures that exercise it. It also supports the distinction raised in -[#32](https://github.com/IABTechLab/OpenProposal/issues/32): resolving a format, -fetching a document, checking conformance and receiving seller approval are -separate results. - -The normative change adds the inspection subsection to section 5.4 and a note -to the CTV example. It adds no fields or enum values. The appendix field -indexes therefore remain unchanged. - -## Reproduce the document inspection - -``` -python3 scripts/check_creative_fixtures.py -python3 scripts/check_creative_fixtures.py --json -``` - -The standard-library runner starts a loopback HTTP server. The server records -every request, including requests to synthetic notification traps. Nothing -loads a campaign URL. The two VAST templates use fixture substitution tokens -that the runner replaces with the server's loopback address. - -The reference inspector retrieves only document request identities listed by -the test harness. It follows a single-ad wrapper chain, hashes retrieved bytes and -parses XML. It does not execute code, fetch media, probe event URLs, expand -macros, use proxy environment variables, or automatically follow redirects. -An HTTP redirect can itself target an event endpoint. It must not become an -unchecked route around the retrieval policy. - -The allowlist, event guard and cycle detection share one HTTP URL identity -function. It removes fragments, normalizes scheme/host spelling, IPv6 spelling -and default ports, and supplies `/` for an empty path. The transport sends that -same normalized URL. Path spelling, query order, percent encodings and an empty -query delimiter are preserved. A receipt retains both the supplied document URL -and the actual request URL. Unsupported schemes, credentials, control characters -and non-ASCII URLs are blocked in this deliberately limited fixture profile. - -The event-role set includes linear, companion, non-linear and icon tracking, -viewability, verification and error URIs. Clickthrough destinations are also -excluded from wrapper retrieval. Regression cases deliberately allowlist targets -that are also declared as events, so an absent allowlist entry cannot hide a -broken event guard. Fragment aliases are tested on both sides of that comparison. -This does not infer server-side URL aliases, custom extension semantics or -resource intent. The caller must establish that its initial resource allowlist -is appropriate. It is not a production fetch policy. - -Successful wrapper traversal reaches the inline document with no requests to -its impression, playback, click, viewability, verification or error endpoints. -Failure cases include cycles, missing documents, malformed XML, a redirect to -an event URL, excessive size, excessive depth and an event URL also used as a -wrapper target. Unresolved macros, unsupported encodings, DTDs and ad pods are -reported as not checked within this limited profile. Missing wrapper targets -and malformed XML fail their specific structural checks. - -The server-side positive control deliberately sends GET and HEAD to a local -notification trap and verifies both are recorded. This establishes that zero -notification requests in the inspection cases is an observable test result. -Neither response availability nor HTTP method makes an event request safe. - -## Reproduce the browser preview - -Requires Node.js 22 or later and an installed Chrome or Chromium executable. -Set `CHROME_BIN` if it is not at one of the common paths checked by the runner. -`CREATIVE_CACHE_DIR` can place the temporary browser profile in a chosen cache -directory. The directory must already exist. The profile is removed on exit. - -``` -node scripts/check_creative_preview.mjs -node scripts/check_creative_preview.mjs --json -node scripts/check_preview_observations.mjs -``` - -The browser runs synthetic creative that attempts a GET, HEAD, POST, beacon, -XHR, image, script, nested frame, CSS image, video poster, form submission, -navigation and WebSocket connection. Each mechanism has a positive control -that must reach its loopback notification trap using the expected method. -Each isolated case must execute the same attempt and produce zero requests -at that trap. A receipt includes the browser version, attempted operation, -server observations, blocked requests and a 500 ms observation interval -after the synchronous attempt. - -The assertion checks the entire notification-trap log for that case, including -unexpected paths and methods. The receipt preserves every observation and its -full URL. Control cases must reach only their expected notification endpoint; -isolated cases must produce no notification at all. The assertion regression -tests run without a browser and are included in CI. - -An integration negative control checks that an unexpected notification cannot -disappear from the result: - -``` -node scripts/check_creative_preview.mjs --json --inject-notification-canary -``` - -That command intentionally exits with status 1. The test driver injects one -loopback event at an unexpected path in `fetch-get/isolated`; that case must -fail and its receipt must contain the event. This is a check of the assertion, -not a browser escape or a live campaign notification. - -The isolated preview combines a sandboxed frame, a restrictive Content -Security Policy, browser request interception and an independent HTTP proxy -that permits only the exact preview-document request. Proxy bypass for -loopback is disabled. The proxy rejects CONNECT except for the WebSocket -positive control's loopback tunnel. It permits a control's specific loopback -event URL only while that control runs. It never forwards to external -destinations. A separate navigation case disables CSP and browser interception -to verify that the independent gate itself blocks the notification. - -The independent gate is essential to this demonstration. An initial test -using CSP and target-scoped browser interception alone allowed frame -navigation to reach the synthetic event endpoint in Chrome. The navigation -case remains in the suite and must be blocked outside the renderer too. -This finding supports a requirement about observable notifications rather -than prescribing a particular browser API as sufficient isolation. - -These are finite synthetic tests, not proof that this harness safely executes -arbitrary third-party code. It is not a production sandbox. Delayed traffic, -all possible protocols, browser vulnerabilities, WebRTC, native applications -and real verification SDKs are outside the test coverage. A production -inspector needs enforcement for its entire execution environment and must -decline a check it cannot isolate. The browser fixture demonstrates request -suppression; it does not demonstrate video decoding or playback conformance. - -## Report the exact check and its evidence - -The JSON receipt is a non-normative sidecar for these tests, not a new -OpenProposal object or an adopted readiness protocol. Each result names the -check, its outcome and its reason. Retrieved documents carry their SHA-256 -hash, URL and byte count. The receipt also identifies the asset, inspection -time, root URL, depth/size limits, redirect policy and macro context. - -- `passed`: the named check ran and met that check's condition. Parsing XML - does not pass VAST schema validation. Reaching `InLine` does not pass - playback, media decode, seller specs or seller approval. -- `failed`: the named check ran and found a concrete violation, such as - malformed XML or a cycle in the retrieved wrapper chain. -- `not_checked`: the check did not establish an outcome. The receipt states - whether data was missing, retrieval failed, policy blocked it or the - implementation did not support the check. These reasons do not establish - that the creative itself is invalid. - -A required check set is satisfied only if every named check has at least one -result and all its results passed. Missing, failed or not-checked results -cannot satisfy it. An empty requirement list cannot establish satisfaction. -Even satisfied inspection checks do not establish seller acceptance. -`readiness` remains `not_assessed` in this first profile. - -The inspector leaves VAST schema conformance, resolved format membership, -seller-spec conformance, media decode, verification execution, live playback -and seller approval explicitly not checked. A minimal `` fixture is -included to prove that successful traversal never becomes a schema pass. - -## Boundaries for the next creative contribution - -Readiness needs a declared check set for the resolved format and delivery -protocol. An audit needs the exact asset bytes, resolved wrapper documents, -seller-spec version, execution context, checker version and evidence expiry. -Mutable URLs or changed specifications cannot inherit an earlier pass without -a revalidation rule. Those are follow-up decisions, not implicit guarantees -of this fixture receipt. - -This proposal does not require inspectors to fetch documents or offer a -preview. It does not change VAST's live-delivery tracking requirements. -Synthetic event delivery to a documented receiver test mode would need its -own evidence that the receiver excludes it from production measurement. -An inspector-owned `test=true` parameter cannot establish that fact. - -References: [VAST 4.3, sections 2.3.4 and 2.3.6](https://iabtechlab.com/wp-content/uploads/2022/09/VAST_4.3.pdf), -[Content Security Policy](https://www.w3.org/TR/CSP3/), and -[Chrome DevTools Fetch domain](https://chromedevtools.github.io/devtools-protocol/tot/Fetch/). diff --git a/examples/line-item-ctv.yaml b/examples/line-item-ctv.yaml index 0487e17..3a68e0d 100644 --- a/examples/line-item-ctv.yaml +++ b/examples/line-item-ctv.yaml @@ -34,5 +34,4 @@ transaction_mechanism: { available: ["PRIVATE_AUCTION"], included: ["PRIVATE_AUC serving_mode: ssai correlation_id: "ep-q4-2026-commitment/weather-app-ctv" -# Inspection and previews must not send production tracking or error notifications. -# See the creative inspection requirements in spec section 5.4. +# Inspecting this line item's VAST creative is subject to spec section 5.4. diff --git a/fixtures/creative/inline.xml b/fixtures/creative/inline.xml deleted file mode 100644 index 84a50ec..0000000 --- a/fixtures/creative/inline.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - OpenProposal inspection fixture - Synthetic inspection asset - - synthetic-serving-id - - - - - - - - - - synthetic - - - - - - - - synthetic-asset - - 00:00:15 - - - - - - - - - - - - - - - - - diff --git a/fixtures/creative/wrapper.xml b/fixtures/creative/wrapper.xml deleted file mode 100644 index c999551..0000000 --- a/fixtures/creative/wrapper.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - OpenProposal inspection fixture - - - - - - - - - - - - - - - diff --git a/scripts/check_creative_fixtures.py b/scripts/check_creative_fixtures.py deleted file mode 100644 index 0bd8bf8..0000000 --- a/scripts/check_creative_fixtures.py +++ /dev/null @@ -1,209 +0,0 @@ -#!/usr/bin/env python3 -"""Exercise inspection against local resources and local notification traps.""" - -import argparse -import json -import threading -import urllib.request -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path - -from inspect_creative_fixture import FixtureTransport, Unchecked, checks_satisfied, inspect, request_url - -ROOT = Path(__file__).resolve().parents[1] - - -def run(): - requests, routes = [], {} - - class Handler(BaseHTTPRequestHandler): - def do_GET(self): - requests.append({"method": "GET", "path": self.path}) - if self.path == "/redirect": - self.send_response(302) - self.send_header("Location", "/events/redirect") - self.end_headers() - return - status, body = routes.get(self.path, (404, b"not found")) - self.send_response(status) - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def do_HEAD(self): - requests.append({"method": "HEAD", "path": self.path}) - self.send_response(204) - self.end_headers() - - def log_message(self, *args): - pass - - server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - base = "http://127.0.0.1:" + str(server.server_port) - inline = (ROOT / "fixtures/creative/inline.xml").read_text().replace("{{BASE}}", base) - wrapper = (ROOT / "fixtures/creative/wrapper.xml").read_text().replace("{{BASE}}", base) - - def wrap(target): - return wrapper.replace("{{TARGET}}", base + target).encode() - - routes.update({ - "/inline": (200, inline.encode()), "/wrapper": (200, wrap("/inline")), - "/cycle": (200, wrap("/cycle")), "/broken-wrapper": (200, wrap("/missing")), - "/external": (200, wrapper.replace("{{TARGET}}", "https://unreachable.example.test/ad").encode()), - "/macro": (200, wrap("/inline?context=[PAGEURL]")), - "/malformed": (200, b""), - "/not-vast": (200, b""), - "/doctype": (200, b']>&x;'), - "/utf16": (200, inline.encode("utf-16")), - "/large": (200, b" " * 65537), "/empty": (200, b''), - "/pod": (200, b''), - "/missing-target": (200, b''), - "/both": (200, b''), - "/dual-role": (200, wrap("/events/wrapper-impression")), - "/events/wrapper-impression": (200, inline.encode()), - "/inline-minimal": (200, b''), - }) - allowed = {base + path for path in routes if not path.startswith("/events/")} | {base + "/missing", base + "/redirect"} - definitions = [ - ("wrapper-to-inline", "/wrapper", "wrapper_resolution", "passed", ["/wrapper", "/inline"], 5), - ("inline-document", "/inline", "wrapper_resolution", "passed", ["/inline"], 5), - ("wrapper-cycle", "/cycle", "wrapper_resolution", "failed", ["/cycle"], 5), - ("wrapper-http-failure", "/broken-wrapper", "document_retrieval", "not_checked", ["/broken-wrapper", "/missing"], 5), - ("redirect-to-event-blocked", "/redirect", "document_retrieval", "not_checked", ["/redirect"], 5), - ("external-target-blocked", "/external", "document_retrieval", "not_checked", ["/external"], 5), - ("unresolved-macro-blocked", "/macro", "document_retrieval", "not_checked", ["/macro"], 5), - ("malformed-xml", "/malformed", "xml_well_formedness", "failed", ["/malformed"], 5), - ("wrong-root", "/not-vast", "vast_root", "failed", ["/not-vast"], 5), - ("doctype-not-executed", "/doctype", "xml_well_formedness", "not_checked", ["/doctype"], 5), - ("unsupported-encoding", "/utf16", "xml_well_formedness", "not_checked", ["/utf16"], 5), - ("document-size-limit", "/large", "document_retrieval", "not_checked", ["/large"], 5), - ("empty-response-not-conformance", "/empty", "wrapper_resolution", "not_checked", ["/empty"], 5), - ("ad-pod-outside-profile", "/pod", "wrapper_resolution", "not_checked", ["/pod"], 5), - ("wrapper-target-missing", "/missing-target", "wrapper_resolution", "failed", ["/missing-target"], 5), - ("ambiguous-ad-shape", "/both", "wrapper_resolution", "failed", ["/both"], 5), - ("document-count-limit", "/wrapper", "wrapper_resolution", "not_checked", ["/wrapper"], 1), - ("event-also-used-as-wrapper-target", "/dual-role", "wrapper_resolution", "not_checked", ["/dual-role"], 5), - ("minimal-inline-is-not-schema-validation", "/inline-minimal", "vast_schema_conformance", "not_checked", ["/inline-minimal"], 5), - ] - dual_allowed = {"/dual-role": {base + "/events/wrapper-impression"}} - expected_event_guard = set() - - def add_guard_case(name, xml, target): - path = "/" + name - routes[path] = (200, xml.encode()) - allowed.add(base + path) - dual_allowed[path] = {target} - expected_event_guard.add(path) - definitions.append((name, path, "wrapper_resolution", "not_checked", [path], 5)) - - event = base + "/events/wrapper-impression" - add_guard_case("fragment-on-wrapper-target", wrapper.replace("{{TARGET}}", event + "#inspection"), event + "#inspection") - add_guard_case("fragment-on-event-url", wrapper.replace(event, event + "#measurement").replace("{{TARGET}}", event), event) - add_guard_case("scheme-case-event-alias", wrapper.replace("{{TARGET}}", event.replace("http:", "HTTP:")), event.replace("http:", "HTTP:")) - routes["/cycle-fragment"] = (200, wrap("/cycle-fragment#again")) - allowed.update({base + "/cycle-fragment", base + "/cycle-fragment#again"}) - definitions.append(("fragment-alias-cycle", "/cycle-fragment", "wrapper_resolution", "failed", ["/cycle-fragment"], 5)) - - # Fixed VAST roles, intentionally independent of the inspector's role set. - # Each target is allowlisted to prove the semantic guard, not a missing - # allowlist entry, prevents the request. - tracking = base + "/events/shared" - routes["/events/shared"] = (200, inline.encode()) - resource = f'' - for role in ("CompanionClickTracking", "CompanionClickThrough", - "NonLinearClickTracking", "NonLinearClickThrough", - "IconViewTracking", "IconClickTracking", "IconClickThrough"): - tag = f'<{role}>' - xml = wrapper.replace("{{TARGET}}", tracking) - if role.startswith("Companion"): - extra = f'{resource}{tag}' - xml = xml.replace("", extra + "") - elif role.startswith("NonLinear"): - extra = f'{resource}{tag}' - xml = xml.replace("", extra + "") - else: - if role != "IconViewTracking": - tag = "" + tag + "" - extra = f'{resource}{tag}' - xml = xml.replace("", extra + "") - add_guard_case("event-role-" + role, xml, tracking) - - results = [] - try: - for name, path, check, outcome, expected_requests, limit in definitions: - requests.clear() - # Dual-role case deliberately allows the URL to prove the semantic - # event guard stops it before the transport allowlist is consulted. - permitted = allowed | dual_allowed.get(path, set()) - receipt = inspect(base + path, FixtureTransport(permitted), max_documents=limit) - matches = [item for item in receipt["checks"] if item["check"] == check] - notifications = [item for item in requests if item["path"].startswith("/events/")] - passed = (bool(matches) and matches[-1]["outcome"] == outcome and not notifications - and [item["path"] for item in requests] == expected_requests - and not checks_satisfied(receipt, ["vast_schema_conformance"]) - and not checks_satisfied(receipt, ["unknown_required_check"]) - and not checks_satisfied(receipt, []) and receipt["readiness"] == "not_assessed") - if path == "/wrapper" and limit == 5: - passed = passed and checks_satisfied(receipt, ["document_retrieval", "xml_well_formedness", "wrapper_resolution"]) - passed = passed and len(receipt["identified_event_urls"]) == 13 - if path in expected_event_guard: - passed = passed and matches[-1]["reason"] == "target is also identified as an event URL" - passed = passed and not checks_satisfied(receipt, ["document_retrieval", "xml_well_formedness", "wrapper_resolution"]) - results.append({"id": name, "passed": bool(passed), "expected_check": check, - "expected_outcome": outcome, "server_requests": requests.copy(), "receipt": receipt}) - # A positive control proves the trap records notifications, including HEAD. - # These endpoints are synthetic and loopback-only, never campaign URLs. - requests.clear() - opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) - for method in ("GET", "HEAD"): - try: - with opener.open(urllib.request.Request(base + "/events/control", method=method)): - pass - except urllib.error.HTTPError: - pass - results.append({"id": "notification-trap-positive-control", "passed": requests == [ - {"method": "GET", "path": "/events/control"}, {"method": "HEAD", "path": "/events/control"}], - "server_requests": requests.copy()}) - url_cases = [ - ("url-fragment-removed", "http://example.test/events/x#preview", "http://example.test/events/x"), - ("url-http-authority-normalized", "HTTP://EXAMPLE.TEST:80/events/x", "http://example.test/events/x"), - ("url-https-default-port", "https://EXAMPLE.TEST:443/events/x", "https://example.test/events/x"), - ("url-empty-path", "http://example.test", "http://example.test/"), - ("url-ipv6-normalized", "http://[0:0:0:0:0:0:0:1]:80/x", "http://[::1]/x"), - ("url-query-preserved", "http://example.test/x?signature=%7e&b=2&a=1#preview", "http://example.test/x?signature=%7e&b=2&a=1"), - ("url-empty-query-preserved", "http://example.test/x?#preview", "http://example.test/x?"), - ] - for name, value, expected in url_cases: - actual = request_url(value) - results.append({"id": name, "passed": actual == expected, "input": value, "expected": expected, "actual": actual}) - for name, value in (("url-credentials-blocked", "http://user:password@example.test/x"), - ("url-non-http-blocked", "file:///events/x"), - ("url-control-character-blocked", "http://example.test/\nx")): - try: - request_url(value) - blocked = False - except Unchecked: - blocked = True - results.append({"id": name, "passed": blocked, "expected": "not_checked"}) - return {"profile": "creative-inspection-discussion-2", "passed": sum(item["passed"] for item in results), - "total": len(results), "cases": results} - finally: - server.shutdown() - server.server_close() - thread.join() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--json", action="store_true") - args = parser.parse_args() - report = run() - if args.json: - print(json.dumps(report, indent=2)) - else: - for case in report["cases"]: - print(("PASS " if case["passed"] else "FAIL ") + case["id"]) - print(f'{report["passed"]}/{report["total"]} creative inspection cases pass') - raise SystemExit(report["passed"] != report["total"]) diff --git a/scripts/check_creative_preview.mjs b/scripts/check_creative_preview.mjs deleted file mode 100644 index e650814..0000000 --- a/scripts/check_creative_preview.mjs +++ /dev/null @@ -1,269 +0,0 @@ -#!/usr/bin/env node -// Synthetic loopback preview tests. Never load live creative with this harness. -import { spawn } from 'node:child_process'; -import { createServer, request as httpRequest } from 'node:http'; -import { connect } from 'node:net'; -import { mkdtemp, readFile, rm, access } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { once } from 'node:events'; -import { assessNotifications } from './creative_preview_observations.mjs'; - -const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); -const attempts = { - 'fetch-get': `fetch(url).catch(() => {})`, - 'fetch-head': `fetch(url, {method: 'HEAD'}).catch(() => {})`, - 'fetch-post': `fetch(url, {method: 'POST', body: 'synthetic'}).catch(() => {})`, - beacon: `navigator.sendBeacon(url, 'synthetic')`, - xhr: `const x = new XMLHttpRequest(); x.open('GET', url); x.send()`, - image: `const x = new Image(); x.src = url; document.body.append(x)`, - script: `const x = document.createElement('script'); x.src = url; document.body.append(x)`, - iframe: `const x = document.createElement('iframe'); x.src = url; document.body.append(x)`, - 'css-image': `const x = document.createElement('div'); x.style.cssText = 'width:40px;height:40px;background-image:url(' + url + ')'; document.body.append(x)`, - 'video-poster': `const x = document.createElement('video'); x.poster = url; document.body.append(x)`, - form: `const x = document.createElement('form'); x.method = 'POST'; x.action = url; document.body.append(x); x.submit()`, - navigation: `location.href = url`, - websocket: `const x = new WebSocket(url.replace('http:', 'ws:')); x.onerror = () => {}`, -}; -const jsonMode = process.argv.includes('--json'); -const injectCanary = process.argv.includes('--inject-notification-canary'); -const csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"; -const escapeAttribute = value => value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<'); -let base; -const received = []; -const gateBlocked = []; -let allowedRequests = new Set(); -let allowControlTunnel = false; -// A separate HTTP proxy enforces the fixture policy across renderer changes. -// CSP and target-scoped interception alone missed a navigation in testing. -const gate = createServer((request, response) => { - let url; - try { url = new URL(request.url); } catch { response.writeHead(400).end(); return; } - if (url.origin !== base || !allowedRequests.has(request.method + ' ' + url.href)) { - gateBlocked.push({method: request.method, url: url.href}); - response.writeHead(403).end(); return; - } - const upstream = httpRequest(url, {method: request.method, headers: request.headers}, result => { - response.writeHead(result.statusCode, result.headers); result.pipe(response); - }); - upstream.on('error', () => response.writeHead(502).end()); - request.pipe(upstream); -}); -gate.on('connect', (request, socket, head) => { - if (allowControlTunnel && request.url === new URL(base).host) { - const upstream = connect(server.address().port, '127.0.0.1', () => { - socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); - if (head.length) upstream.write(head); - socket.pipe(upstream); upstream.pipe(socket); - }); - upstream.on('error', () => socket.destroy()); socket.on('error', () => upstream.destroy()); - socket.on('close', () => upstream.destroy()); - return; - } - gateBlocked.push({method: 'CONNECT', url: request.url}); - socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); -}); -gate.on('upgrade', (request, socket, head) => { - let url; - try { url = new URL(request.url); } catch { socket.destroy(); return; } - // WebSocket proxy requests use ws: while the fixture allowlist uses http:. - const normalized = url.href.replace(/^ws:/, 'http:'); - if (!normalized.startsWith(base + '/') || !allowedRequests.has('UPGRADE ' + normalized)) { - gateBlocked.push({method: 'UPGRADE', url: url.href}); - socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); return; - } - const upstream = connect(server.address().port, '127.0.0.1', () => { - upstream.write(request.method + ' ' + url.pathname + ' HTTP/1.1\r\n' + - Object.entries(request.headers).map(([key, value]) => key + ': ' + value).join('\r\n') + '\r\n\r\n'); - if (head.length) upstream.write(head); - socket.pipe(upstream); upstream.pipe(socket); - }); - upstream.on('error', () => socket.destroy()); socket.on('error', () => upstream.destroy()); - socket.on('close', () => upstream.destroy()); -}); -const server = createServer((request, response) => { - const url = new URL(request.url, base); - if (url.pathname === '/preview') { - const id = url.searchParams.get('case'); - const isolated = url.searchParams.get('mode') === 'isolated'; - const operation = attempts[id]; - if (!operation) { response.writeHead(400).end(); return; } - const eventUrl = base + '/events/' + id; - const frame = `${isolated ? `` : ''}`; - const html = `Synthetic creative inspection - - `; - response.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' }); - response.end(html); - } else if (url.pathname.startsWith('/events/')) { - received.push({ method: request.method, path: url.pathname, url: url.href }); - response.writeHead(200, { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' }); - response.end('synthetic notification trap'); - } else { - response.writeHead(404).end(); - } -}); -server.on('upgrade', (request, socket) => { - const url = new URL(request.url, base); - received.push({ method: 'UPGRADE', path: url.pathname, url: url.href }); - socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); -}); - -class CDP { - constructor(socket) { - this.socket = socket; this.id = 0; this.pending = new Map(); this.handlers = []; - socket.addEventListener('message', ({data}) => { - const message = JSON.parse(data); - if (message.id) { - const pending = this.pending.get(message.id); - if (!pending) return; - this.pending.delete(message.id); clearTimeout(pending.timer); - if (message.error) pending.reject(new Error(JSON.stringify(message.error))); - else pending.resolve(message.result); - } else for (const handler of this.handlers) handler(message); - }); - } - call(method, params = {}, sessionId) { - const id = ++this.id; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { this.pending.delete(id); reject(new Error('CDP timed out: ' + method)); }, 10000); - this.pending.set(id, {resolve, reject, timer}); - this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? {sessionId} : {}) })); - }); - } - close() { this.socket.close(); } -} - -async function findChrome() { - const paths = process.env.CHROME_BIN ? [process.env.CHROME_BIN] : [ - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', - ]; - for (const path of paths) { try { await access(path); return path; } catch {} } - throw new Error('Chrome not found. Set CHROME_BIN to a Chrome/Chromium executable.'); -} - -let chrome, profile, browser, cdp; -const cases = []; -try { - const executable = await findChrome(); - profile = await mkdtemp(join(process.env.CREATIVE_CACHE_DIR || tmpdir(), 'openproposal-preview-')); - server.listen(0, '127.0.0.1'); await once(server, 'listening'); - base = 'http://127.0.0.1:' + server.address().port; - gate.listen(0, '127.0.0.1'); await once(gate, 'listening'); - chrome = spawn(executable, ['--headless=new', '--remote-debugging-port=0', '--user-data-dir=' + profile, - '--no-first-run', '--no-default-browser-check', '--disable-background-networking', - '--disable-component-update', '--disable-sync', '--disable-quic', - '--proxy-server=http://127.0.0.1:' + gate.address().port, '--proxy-bypass-list=<-loopback>', - 'about:blank'], {stdio: 'ignore'}); - let port; - for (let i = 0; i < 100; i++) { - try { port = (await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]; break; } catch {} - if (chrome.exitCode !== null) throw new Error('Chrome exited before DevTools started'); - await delay(100); - } - if (!port) throw new Error('Chrome did not start DevTools'); - const endpoint = 'http://127.0.0.1:' + port; - browser = await (await fetch(endpoint + '/json/version')).json(); - for (const id of Object.keys(attempts)) { - for (const mode of ['control', 'isolated', ...(id === 'navigation' ? ['network-gate'] : [])]) { - const target = await (await fetch(endpoint + '/json/new?about:blank', {method: 'PUT'})).json(); - const socket = new WebSocket(target.webSocketDebuggerUrl); - await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, {once:true}); socket.addEventListener('error', reject, {once:true}); }); - cdp = new CDP(socket); - const blocked = [], protocolErrors = [], pendingEvents = new Set(); - const previewUrl = base + '/preview?case=' + id + '&mode=' + mode; - const isolated = mode !== 'control'; - const expectedMethod = {'fetch-head':'HEAD', 'fetch-post':'POST', beacon:'POST', form:'POST', websocket:'UPGRADE'}[id] || 'GET'; - allowedRequests = new Set(['GET ' + previewUrl]); - if (!isolated) allowedRequests.add(expectedMethod + ' ' + base + '/events/' + id); - allowControlTunnel = id === 'websocket' && mode === 'control'; - gateBlocked.length = 0; - async function guard(sessionId) { - await cdp.call('Fetch.enable', {patterns: [{urlPattern: '*', requestStage: 'Request'}]}, sessionId); - await cdp.call('Target.setAutoAttach', {autoAttach: true, waitForDebuggerOnStart: true, flatten: true}, sessionId); - } - cdp.handlers.push(message => { - const work = (async () => { - if (message.method === 'Fetch.requestPaused') { - const {requestId, request} = message.params; - if (request.url === previewUrl && request.method === 'GET') { - await cdp.call('Fetch.continueRequest', {requestId}, message.sessionId); - } else { - blocked.push({url: request.url, method: request.method}); - await cdp.call('Fetch.failRequest', {requestId, errorReason: 'BlockedByClient'}, message.sessionId); - } - } else if (message.method === 'Target.attachedToTarget' && isolated) { - await guard(message.params.sessionId); - await cdp.call('Runtime.runIfWaitingForDebugger', {}, message.params.sessionId); - } - })().catch(error => protocolErrors.push(error.message)); - pendingEvents.add(work); work.finally(() => pendingEvents.delete(work)); - }); - await cdp.call('Page.enable'); await cdp.call('Runtime.enable'); - if (mode === 'isolated') await guard(); - const observationStart = received.length; - await cdp.call('Page.navigate', {url: previewUrl}); - let messages = []; - for (let i = 0; i < 100; i++) { - const state = await cdp.call('Runtime.evaluate', {expression: 'window.fixtureMessages || []', returnByValue: true}); - messages = state.result.value || []; - if (messages.some(message => message.attempted === id)) break; - await delay(25); - } - // Finite observation window is reported with the receipt, not a promise - // that arbitrary creative could not schedule traffic at a later time. - await delay(500); - await Promise.all([...pendingEvents]); - const state = await cdp.call('Runtime.evaluate', {expression: 'window.fixtureMessages || []', returnByValue: true}); - messages = state.result.value || []; - // Negative control for the assertion itself, sent by this test driver. - // A run with this flag must fail and retain the unexpected event. - if (injectCanary && mode === 'isolated' && id === 'fetch-get') { - await fetch(base + '/events/unexpected-audit-canary'); - } - const observation = assessNotifications(received.slice(observationStart), { - isolated, expectedPath: '/events/' + id, expectedMethod, - }); - const notifications = observation.notifications; - const attempted = messages.some(message => message.attempted === id); - const unexpectedScriptError = messages.some(message => message.error && message.error !== 'SecurityError'); - const passed = attempted && !unexpectedScriptError && protocolErrors.length === 0 && observation.passed; - cases.push({id: id + '/' + mode, passed, attempted, expected_method: expectedMethod, - server_notifications: notifications, blocked_requests: blocked, - network_gate_blocks: gateBlocked.slice(), messages, - protocol_errors: protocolErrors, observation_ms_after_attempt: 500}); - await fetch(endpoint + '/json/close/' + target.id); - cdp.close(); cdp = null; - } - } - const report = {profile: 'synthetic-browser-inspection-2', browser: browser.Browser, - assertion_canary_injected: injectCanary, - tested_at: new Date().toISOString(), passed: cases.filter(item => item.passed).length, - total: cases.length, cases}; - if (jsonMode) console.log(JSON.stringify(report, null, 2)); - else { - for (const item of cases) console.log((item.passed ? 'PASS ' : 'FAIL ') + item.id); - console.log(`${report.passed}/${report.total} synthetic browser cases pass (${browser.Browser})`); - } - if (report.passed !== report.total) process.exitCode = 1; -} finally { - cdp?.close(); - if (chrome && chrome.exitCode === null && chrome.signalCode === null) { - chrome.kill('SIGTERM'); - await Promise.race([once(chrome, 'exit'), delay(3000)]); - if (chrome.exitCode === null && chrome.signalCode === null) { - chrome.kill('SIGKILL'); await once(chrome, 'exit'); - } - } - server.closeAllConnections(); - await new Promise(resolve => server.close(resolve)); - gate.closeAllConnections(); - await new Promise(resolve => gate.close(resolve)); - if (profile) await rm(profile, {recursive: true, force: true, maxRetries: 5, retryDelay: 100}); -} diff --git a/scripts/check_preview_observations.mjs b/scripts/check_preview_observations.mjs deleted file mode 100644 index ea39205..0000000 --- a/scripts/check_preview_observations.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import assert from 'node:assert/strict'; -import { assessNotifications } from './creative_preview_observations.mjs'; - -const expectedPath = '/events/fetch-get'; -const expectedMethod = 'GET'; -const cases = [ - {id:'empty-isolated-log', isolated:true, observations:[], expected:true}, - {id:'expected-event-is-still-forbidden', isolated:true, observations:[{method:'GET',path:expectedPath}], expected:false}, - {id:'unexpected-event-path-is-forbidden', isolated:true, observations:[{method:'GET',path:'/events/unexpected-audit-canary'}], expected:false}, - {id:'unexpected-event-method-is-forbidden', isolated:true, observations:[{method:'HEAD',path:'/events/unexpected'}], expected:false}, - {id:'mixed-events-are-all-preserved', isolated:true, observations:[{method:'GET',path:expectedPath},{method:'POST',path:'/events/other'}], expected:false}, - {id:'control-must-reach-trap', isolated:false, observations:[], expected:false}, - {id:'expected-control-event', isolated:false, observations:[{method:'GET',path:expectedPath}], expected:true}, - {id:'wrong-control-path', isolated:false, observations:[{method:'GET',path:'/events/other'}], expected:false}, - {id:'wrong-control-method', isolated:false, observations:[{method:'HEAD',path:expectedPath}], expected:false}, - {id:'control-with-extra-notification', isolated:false, observations:[{method:'GET',path:expectedPath},{method:'GET',path:'/events/other'}], expected:false}, -]; -const results = cases.map(vector => { - const result = assessNotifications(vector.observations, {isolated:vector.isolated, expectedPath, expectedMethod}); - // The receipt must retain the complete observations, even when they fail. - assert.deepEqual(result.notifications, vector.observations); - assert.notEqual(result.notifications, vector.observations); - return {id:vector.id, passed:result.passed === vector.expected, expected_check_result:vector.expected, actual:result}; -}); -const report = {profile:'preview-notification-assertions-1', passed:results.filter(x=>x.passed).length, total:results.length, cases:results}; -if(process.argv.includes('--json')) console.log(JSON.stringify(report,null,2)); -else {for(const item of results) console.log((item.passed?'PASS ':'FAIL ')+item.id);console.log(`${report.passed}/${report.total} notification assertions pass`);} -if(report.passed !== report.total) process.exitCode=1; diff --git a/scripts/creative_preview_observations.mjs b/scripts/creative_preview_observations.mjs deleted file mode 100644 index 76c2880..0000000 --- a/scripts/creative_preview_observations.mjs +++ /dev/null @@ -1,9 +0,0 @@ -// Shared assertion for the synthetic notification trap, not an egress filter. -export function assessNotifications(observations, {isolated, expectedPath, expectedMethod}) { - const notifications = observations.map(observation => ({...observation})); - const expected = observation => observation.path === expectedPath && observation.method === expectedMethod; - return { - passed: isolated ? notifications.length === 0 : notifications.length > 0 && notifications.every(expected), - notifications, - }; -} diff --git a/scripts/inspect_creative_fixture.py b/scripts/inspect_creative_fixture.py deleted file mode 100644 index 0c0780b..0000000 --- a/scripts/inspect_creative_fixture.py +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env python3 -"""Bounded, fixture-only document inspection supporting OpenProposal #42. - -Not a production fetcher, player, VAST validator or creative-readiness service. -""" - -import hashlib -import ipaddress -import urllib.error -import urllib.request -from urllib.parse import urlsplit, urlunsplit -import xml.etree.ElementTree as ET -from datetime import datetime, timezone - -EVENT_ELEMENTS = {"Impression", "Error", "Tracking", "ClickTracking", "ClickThrough", - "CustomClick", "Viewable", "NotViewable", "ViewUndetermined", - "CompanionClickTracking", "CompanionClickThrough", - "NonLinearClickTracking", "NonLinearClickThrough", - "IconViewTracking", "IconClickTracking", "IconClickThrough"} - - -class Unchecked(Exception): - """The check could not run within the fixture's retrieval policy.""" - - -def request_url(url): - """Canonicalize the HTTP request identity used by this fixture transport. - - Fragments never reach the server. Preserve path/query spelling because - application routing and signatures can depend on it. This cannot identify - arbitrary server-side aliases, redirects or the meaning of a resource. - """ - if not isinstance(url, str) or not url.isascii() or any(ord(c) <= 32 or ord(c) == 127 for c in url): - raise Unchecked("URL is outside the ASCII fixture profile") - try: - parts = urlsplit(url) - if parts.scheme not in ("http", "https") or not parts.hostname or parts.username is not None or parts.password is not None: - raise ValueError("unsupported authority") - host = parts.hostname.lower() - if ":" in host: - host = "[" + ipaddress.IPv6Address(host).compressed + "]" - port = parts.port - if port is not None and not 1 <= port <= 65535: - raise ValueError("invalid port") - authority = host - if port is not None and port != (80 if parts.scheme == "http" else 443): - authority += ":" + str(port) - normalized = urlunsplit((parts.scheme, authority, parts.path or "/", parts.query, "")) - if not parts.query and "?" in url.split("#", 1)[0]: - normalized += "?" - return normalized - except ValueError as error: - raise Unchecked("URL is outside the HTTP fixture profile") from error - - -class NoRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, req, fp, code, msg, headers, newurl): - return None - - -class FixtureTransport: - def __init__(self, documents, max_bytes=65536): - # Explicit document URLs come from the local fixture server, not the XML. - # Callers must establish each URL is a document resource, not an event. - self.documents = frozenset(request_url(url) for url in documents) - self.max_bytes = max_bytes - self.requests = [] - self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) - - def get(self, url): - url = request_url(url) - if url not in self.documents: - raise Unchecked("document URL is outside the fixture allowlist") - self.requests.append({"method": "GET", "url": url}) - try: - with self.opener.open(url, timeout=2) as response: - data = response.read(self.max_bytes + 1) - except (urllib.error.URLError, TimeoutError, OSError) as error: - raise Unchecked("document retrieval failed or redirected: " + type(error).__name__) from error - if len(data) > self.max_bytes: - raise Unchecked("document exceeds the inspection byte limit") - return data - - -def inspect(root_url, transport, *, asset_id="fixture-asset", max_documents=5): - checks, documents, event_urls, visited = [], [], set(), set() - event_requests = set() - - def record(name, outcome, reason, url=None): - check = {"check": name, "outcome": outcome, "reason": reason} - if url: - check["document_url"] = url - checks.append(check) - - url = root_url - for _ in range(max_documents): - try: - identity = request_url(url) - except Unchecked as error: - record("document_retrieval", "not_checked", str(error), url) - record("wrapper_resolution", "not_checked", "target URL is unsupported", url) - break - if identity in event_requests: - record("wrapper_resolution", "not_checked", "target is also identified as an event URL", url) - break - if identity in visited: - record("wrapper_resolution", "failed", "wrapper cycle in retrieved documents", url) - break - visited.add(identity) - try: - data = transport.get(url) - except Unchecked as error: - record("document_retrieval", "not_checked", str(error), url) - record("wrapper_resolution", "not_checked", "a required document was not retrieved", url) - break - record("document_retrieval", "passed", "retrieved the requested document bytes", url) - documents.append({"url": url, "request_url": identity, - "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}) - try: - xml = data.decode("utf-8") - except UnicodeDecodeError: - record("xml_well_formedness", "not_checked", "fixture reader supports UTF-8 only", url) - record("wrapper_resolution", "not_checked", "document was not parsed", url) - break - if "\x00" in xml or "` +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 @@ -273,12 +279,9 @@ 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 returned through the inspection result or a -separate diagnostic channel. They MUST NOT be reported by firing a production -VAST `` or `verificationNotExecuted` URL. - -A skipped or blocked check MUST NOT be reported as passed. This section does -not waive any required checks. +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 @@ -407,9 +410,7 @@ buyer commitment, and `settable` bounds. See See [`examples/line-item-ctv.yaml`](../examples/line-item-ctv.yaml). -Inspection of this line item's VAST creative, including wrapper retrieval and -previews, MUST follow the inspection requirements in section 5.4. Inspection -MUST NOT send production tracking or error notifications. +Inspecting this line item's VAST creative is subject to section 5.4. ### 6.4 Audio line item From 97a5b10fc893e3d6d74d174cb64518b70cad9d89 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 25 Oct 2026 14:47:00 -0400 Subject: [PATCH 24/47] Add time_zone and the daypart window shape time_zone is the line item's clock for daypart windows and frequency_cap.period: an IANA time zone name or viewer_local. A window is { days[], start, end } in civil time with an exclusive end later than start, up to 24:00; a window that crosses midnight is two windows. --- examples/line-item-audio.yaml | 1 + examples/line-item-ctv.yaml | 1 + examples/line-item-display.yaml | 1 + spec/CHANGELOG.md | 7 +++++++ spec/openproposal-3.0.md | 36 +++++++++++++++++++++++++++------ 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/examples/line-item-audio.yaml b/examples/line-item-audio.yaml index 4ca2f21..03f28ea 100644 --- a/examples/line-item-audio.yaml +++ b/examples/line-item-audio.yaml @@ -14,6 +14,7 @@ production: pricing: { seller_built: { delta_flat: 12000.00 } } frequency_cap: { max: { min: 1, max: 6 }, period: "day", basis: listener } +time_zone: viewer_local pricing: - { cost_method: CPM, gross_rate: 18.00, floor: 15.84, diff --git a/examples/line-item-ctv.yaml b/examples/line-item-ctv.yaml index fda3994..0046dbf 100644 --- a/examples/line-item-ctv.yaml +++ b/examples/line-item-ctv.yaml @@ -10,6 +10,7 @@ formats: { available: ["ctv/instream-15s", "ctv/instream-30s"], creative_delivery: { method: seller_hosted, protocol: vast_4_2, secure_required: true } frequency_cap: { max: { min: 1, max: 4 }, period: "day", basis: household } +time_zone: viewer_local pricing: - { cost_method: CPM, gross_rate: 45.00, floor: 39.60, diff --git a/examples/line-item-display.yaml b/examples/line-item-display.yaml index 808af24..30479ef 100644 --- a/examples/line-item-display.yaml +++ b/examples/line-item-display.yaml @@ -49,6 +49,7 @@ materials_due: "2026-09-24" geo: { allowed: ["US-NY", "US-CA", "US-IL", "US-TX"] } frequency_cap: { max: { min: 1, max: 5 }, period: "day", basis: device } +time_zone: "America/New_York" pricing: - cost_method: CPM diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 1dfacef..55bc65b 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,6 +5,13 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] +- § 5.5 — `time_zone` on the line item is the clock for `daypart` and + `frequency_cap.period`: an IANA time zone name or `viewer_local`, REQUIRED + with either. A `daypart` window is `{ days[], start, end }` in civil time, + with `end` exclusive and later than `start`; a window that crosses midnight + is two windows. `frequency_cap.period` is a calendar period in `time_zone`. + Breaking ([#111](https://github.com/IABTechLab/OpenProposal/issues/111)). + ## [3.0-draft-1] — 2026-09-22 Initial draft published for public comment. Ported from the working-group diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 6940958..06b8336 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -250,16 +250,39 @@ channel. ### 5.5 Targeting envelope -Each field declares bounds, not a value. A bare value means the field is -`seller-set`. +Each targeting field except `time_zone` declares bounds, not a value. A bare +value means the field is `seller-set`. | Field | Type | Mutability | Description | | :--- | :--- | :--- | :--- | | `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 | +| `daypart` | Object | settable | `allowed[]` of `{ days[], start, end }`: `monday`–`sunday`, `HH:MM` civil time, `end` exclusive, up to `24:00` | +| `time_zone` | String | seller-set | IANA time zone name, or `viewer_local` (where the ad renders). The clock for `daypart` and `frequency_cap.period`. REQUIRED with either | | `frequency_cap` | Object | settable | `{ max: { min, max }, period, basis: device \| household \| listener }`. Basis matters: CTV typically caps at household, audio at listener | +**Window shape.** A `daypart` window is `{ days[], start, end }`, with `days[]` +from `monday` to `sunday`, `start` and `end` as `HH:MM`, and `end` exclusive, +later than `start`, and allowed to be `24:00`. A window that crosses midnight +is two windows: + +```yaml +# Late fringe, 23:00 to 01:00, Monday night into Tuesday +daypart: + allowed: + - { days: [monday], start: "23:00", end: "24:00" } + - { days: [tuesday], start: "00:00", end: "01:00" } +time_zone: "America/New_York" +``` + +**Clock.** `time_zone` names a zone in the +[IANA time zone database](https://www.iana.org/time-zones), such as +`America/New_York` or `UTC`. Windows follow daylight-saving rules: a skipped +hour matches no instant; a repeated hour matches both. Like the calendar Day of +[OpenDirect 2.1 `Line.frequencyinterval`](https://github.com/InteractiveAdvertisingBureau/OpenDirect/blob/82bc294314aba017fc639ba634aecc53adfd675f/OpenDirect.v2.1.final.md#object_line), +`frequency_cap.period` is a calendar period in `time_zone`; weeks start on +Monday. + ### 5.6 Commercial terms | Field | Type | Mutability | Description | @@ -469,6 +492,7 @@ body is normative.** | `geo` | 5.5 Targeting envelope | Object | settable | | `device` | 5.5 Targeting envelope | Object | settable | | `daypart` | 5.5 Targeting envelope | Object | settable | +| `time_zone` | 5.5 Targeting envelope | String | seller-set | | `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 | @@ -512,7 +536,7 @@ 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[]` | +| seller-set | 30 | 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`, `time_zone`, `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[]` | @@ -522,8 +546,8 @@ body is normative.** ### 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`, +Of 72 fields, **23 are touched by the buyer** — 12 `selectable`, 5 `settable`, 3 +`supplied` and 3 `requestable`. The remaining 49 are `seller-set`, `immutable`, `derived` or `mixed`. Only the 3 `requestable` fields require a round trip. Everything else the buyer From 9783ec87d102ccb60c106f4b6782650aa1f0a249 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Mon, 26 Oct 2026 11:38:43 +0100 Subject: [PATCH 25/47] Write the daypart day names as one value list List the `days[]` values once in the `daypart` row, as `monday` | ... | `sunday`. Write HH:MM, America/New_York, and UTC as plain text, since they're a format and examples, not field names. The meaning is unchanged. --- spec/openproposal-3.0.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 06b8336..588096a 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -257,12 +257,12 @@ value means the field is `seller-set`. | :--- | :--- | :--- | :--- | | `geo` | Object | settable | `allowed[]` of geo codes the buyer may select within | | `device` | Object | settable | `allowed[]` of device categories | -| `daypart` | Object | settable | `allowed[]` of `{ days[], start, end }`: `monday`–`sunday`, `HH:MM` civil time, `end` exclusive, up to `24:00` | +| `daypart` | Object | settable | `allowed[]` of `{ days[], start, end }`: `days[]` from `monday` \| `tuesday` \| `wednesday` \| `thursday` \| `friday` \| `saturday` \| `sunday`, HH:MM civil time, `end` exclusive, up to `24:00` | | `time_zone` | String | seller-set | IANA time zone name, or `viewer_local` (where the ad renders). The clock for `daypart` and `frequency_cap.period`. REQUIRED with either | | `frequency_cap` | Object | settable | `{ max: { min, max }, period, basis: device \| household \| listener }`. Basis matters: CTV typically caps at household, audio at listener | **Window shape.** A `daypart` window is `{ days[], start, end }`, with `days[]` -from `monday` to `sunday`, `start` and `end` as `HH:MM`, and `end` exclusive, +from Monday to Sunday, `start` and `end` as HH:MM, and `end` exclusive, later than `start`, and allowed to be `24:00`. A window that crosses midnight is two windows: @@ -277,7 +277,7 @@ time_zone: "America/New_York" **Clock.** `time_zone` names a zone in the [IANA time zone database](https://www.iana.org/time-zones), such as -`America/New_York` or `UTC`. Windows follow daylight-saving rules: a skipped +America/New_York or UTC. Windows follow daylight-saving rules: a skipped hour matches no instant; a repeated hour matches both. Like the calendar Day of [OpenDirect 2.1 `Line.frequencyinterval`](https://github.com/InteractiveAdvertisingBureau/OpenDirect/blob/82bc294314aba017fc639ba634aecc53adfd675f/OpenDirect.v2.1.final.md#object_line), `frequency_cap.period` is a calendar period in `time_zone`; weeks start on From 10a3780a91a21251567a9fac579695efc3fee088 Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Wed, 28 Oct 2026 21:20:21 +0530 Subject: [PATCH 26/47] Drop the empty any_of from the requires examples "At least one of" an empty list is never satisfied, so the bonus line would fail a literal check. An absent key sets no condition. Only the examples change; the any_of[] row and the rules stay as filed in #23. Refs #23 --- examples/line-item-ctv-bonus.yaml | 1 - spec/openproposal-3.0.md | 1 - 2 files changed, 2 deletions(-) diff --git a/examples/line-item-ctv-bonus.yaml b/examples/line-item-ctv-bonus.yaml index 6f3acfc..d9ea21f 100644 --- a/examples/line-item-ctv-bonus.yaml +++ b/examples/line-item-ctv-bonus.yaml @@ -14,7 +14,6 @@ frequency_cap: { max: { min: 1, max: 2 }, period: "day", basis: household } requires: all_of: ["business-daily-display", "weather-app-ctv"] - any_of: [] min_commitment: - { line_item_id: "business-daily-display", basis: spend, amount: 50000.00, currency: USD } diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 7de3385..2cb11f8 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -330,7 +330,6 @@ Transitions: `none` → `requested` → `held` → (`expired` | `released` | ```yaml requires: all_of: ["business-daily-display", "weather-app-ctv"] - any_of: [] min_commitment: - { line_item_id: "business-daily-display", basis: spend, amount: 50000.00, currency: USD } ``` From e3cbe66c1ec17d4685113ae87eb37ef26206ef7f Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Wed, 28 Oct 2026 21:20:21 +0530 Subject: [PATCH 27/47] Use the ticket's wording and name the auction floor it means The floor row now says it "neither constrains nor describes the downstream auction floor", as #16 asked, and names that floor: the per-impression floor the exchange applies at auction time (OpenRTB 2.6 Imp.bidfloor). Refs #16 --- spec/openproposal-3.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 1a6dc37..35f095a 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -271,7 +271,7 @@ Each field declares bounds, not a value. A bare value means the field is | `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 seller's offer floor: the lowest rate it will consider at proposal time, so a `requestable` counter can be evaluated without a round trip. Not the downstream auction floor, and not a guarantee that bids at or above it will clear | +| `pricing[].floor` | Number | seller-set | OPTIONAL. The seller's offer floor: the lowest rate it will consider at proposal time, so a `requestable` counter can be evaluated without a round trip. It neither constrains nor describes the downstream auction floor, the per-impression floor the exchange applies at auction time (OpenRTB 2.6 `Imp.bidfloor`), and bids at or above it are not guaranteed to clear | | `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 | From 0b90fd4359628e6bb761ef1b92b2639d1f8337a3 Mon Sep 17 00:00:00 2001 From: Kishore2572 Date: Wed, 28 Oct 2026 21:23:20 +0530 Subject: [PATCH 28/47] Define the replay body and tighten the stale-version checks From the Postindustria review on #73: - a replay matches on the same body, every value the buyer sent except idempotency_key, and returns the stored result whether an acceptance or a rejection; a retry against a new version uses a new key; - version increments on each revision of the proposal, not only while published, so a counter under review also moves it; - "deterministic" now says what the checks depend on: the stored proposal and the time the seller receives the attempt; - the price check names the pricing[] entry the commitment is priced against. Refs #24 --- spec/openproposal-3.0.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 62d37d5..2a92911 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -181,7 +181,7 @@ items. | Field | Type | Mutability | Description | | :--- | :--- | :--- | :--- | | `proposal_id` | String | immutable | Unique identity for the proposal object | -| `version` | Integer | derived | Monotonically increasing revision number. Each revision while status is `published` increments it | +| `version` | Integer | derived | Monotonically increasing revision number. Each revision of the proposal increments it | | `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` | @@ -303,17 +303,21 @@ seller MUST NOT interpret it. The seller MUST reject a new commitment attempt whose `version` is not the current version of the proposal named by `proposal_id`, whose proposal is past -`valid_until`, or whose `pricing[].price_valid_until` has passed. The rejection -MUST return the current version number, so the buyer can fetch it again. These -checks MUST be deterministic, as for `idempotency_key`. +`valid_until`, or where the `pricing[]` entry the commitment is priced against +is past its `price_valid_until`. The rejection MUST return the current version +number, so the buyer can fetch it again. These checks MUST be deterministic: +they depend only on the stored proposal and the time the seller receives the +attempt. For a replay from the same buyer of a commitment attempt the seller has -already processed, with the same `idempotency_key` and the same commitment -terms, the seller MUST return the original result and MUST NOT create a second -commitment. The version and expiry checks apply to a new attempt, not to -returning a stored result. Reusing an `idempotency_key` with different -commitment terms MUST produce an explicit conflict and MUST NOT create or -modify a commitment. +already processed, with the same `idempotency_key` and the same body (every +value the buyer sent with the attempt except `idempotency_key`), the seller +MUST return the original result, whether an acceptance or a rejection, and +MUST NOT create a second commitment. The version and expiry checks apply to a +new attempt, not to returning a stored result. Reusing an `idempotency_key` +with a different body MUST produce an explicit conflict and MUST NOT create or +modify a commitment. A retry against a new `version` is a new attempt, with a +new `idempotency_key`. #### 5.6.2 hold_status From e47cbf566fd6e4d5c0a36c1bedda630775513a76 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Tue, 3 Nov 2026 23:28:42 +0100 Subject: [PATCH 29/47] Add shared types, authoring rules, and an examples checker Give each repeated concept one type in section 2.2, and state the table, presence, type, key, and value rules in section 2.6. Add Type and Required columns to every field and key table. Add the lifecycle, recorded, asserted, and mixed markers, and a line_items[] field. Define the Summary as a strict path subset, with manifest key tables. Delete catalog_ref. Rename the time, reference, delta, cap, and path fields to use the shared types. Number every heading, and keep issue citations and edit history out of normative text. Update the examples, the manifest, the appendices, and the docs. Teach check_appendix.py the new columns and a closed layout list, and add check_examples.py, which checks the examples against the spec's tables. --- .github/workflows/validate.yml | 11 + README.md | 22 +- docs/comment-process.md | 8 +- docs/disposition-log.md | 2 +- docs/open-questions.md | 31 +- examples/line-item-audio.yaml | 15 +- examples/line-item-ctv-bonus.yaml | 9 +- examples/line-item-ctv.yaml | 11 +- examples/line-item-display.yaml | 37 +- examples/manifest.json | 90 ++- examples/proposal.yaml | 5 +- schema/README.md | 8 +- scripts/check_appendix.py | 321 +++++++++-- scripts/check_examples.py | 552 +++++++++++++++++++ spec/CHANGELOG.md | 22 + spec/openproposal-3.0.md | 886 ++++++++++++++++++++---------- 16 files changed, 1580 insertions(+), 450 deletions(-) create mode 100755 scripts/check_examples.py 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 2adbdf2..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,7 +91,7 @@ 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 diff --git a/examples/line-item-audio.yaml b/examples/line-item-audio.yaml index 7ebcf57..66753e9 100644 --- a/examples/line-item-audio.yaml +++ b/examples/line-item-audio.yaml @@ -6,15 +6,15 @@ 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, @@ -25,13 +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 index d9ea21f..0df595e 100644 --- a/examples/line-item-ctv-bonus.yaml +++ b/examples/line-item-ctv-bonus.yaml @@ -10,19 +10,20 @@ 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: { max: { min: 1, max: 2 }, period: "day", basis: household } +frequency_cap: { count: { min: 1, max: 2 }, period: day, per: household } requires: all_of: ["business-daily-display", "weather-app-ctv"] - min_commitment: + min_commitments: - { line_item_id: "business-daily-display", basis: spend, amount: 50000.00, currency: USD } pricing: - - { cost_method: flat, gross_rate: 0.00, + - { 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"] } +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 3012666..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", @@ -31,8 +30,8 @@ hold_status: { hold_available: true, hold_duration: "P14D", 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 spec section 5.4. +# 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 index 7652e1f..7e45029 100644 --- a/examples/manifest.json +++ b/examples/manifest.json @@ -1,65 +1,101 @@ { "seller_id": "reg:examplepub-digital", - "generated_at": "2026-10-01T06:00:00Z", + "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.", - "valid_from": "2026-10-01T00:00:00Z", - "valid_until": "2026-12-31T23:59:59Z", + "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": "weather-app-ctv", - "channel": "ctv", - "properties": [ - "weather-app" - ], + "line_item_id": "streaming-audio", + "channel": "audio", + "environments": { + "available": [ + "web_mobile", + "app_mobile" + ] + }, "formats": { "included": [ - "ctv/instream-15s" + "audio/produced-30s" ] }, - "pricing": { - "cost_method": "CPM", - "gross_rate": 45.0 + "pricing": [ + { + "cost_method": "CPM", + "gross_rate": 18.0, + "currency": "USD" + } + ], + "commitment_bounds": { + "min_budget": 5000.0 }, "committed_metrics": [ { "metric": "completion_rate", - "value": 0.95, + "value": 0.92, "basis": "seller_reported" } ], - "transaction_mechanism": [ - "PRIVATE_AUCTION" - ] + "transaction_mechanism": { + "available": [ + "preferred_deal" + ] + }, + "has_requires": false }, { - "line_item_id": "streaming-audio", - "channel": "audio", + "line_item_id": "weather-app-ctv", + "channel": "ctv", + "environments": { + "available": [ + "ctv" + ] + }, + "properties": { + "available": [ + { + "id": "weather-app" + } + ] + }, "formats": { "included": [ - "audio/produced-30s" + "ctv/instream-15s" ] }, - "pricing": { - "cost_method": "CPM", - "gross_rate": 18.0 + "pricing": [ + { + "cost_method": "CPM", + "gross_rate": 45.0, + "currency": "USD" + } + ], + "commitment_bounds": { + "min_budget": 25000.0 }, "committed_metrics": [ { "metric": "completion_rate", - "value": 0.92, + "value": 0.95, "basis": "seller_reported" } ], - "transaction_mechanism": [ - "PREFERRED_DEAL" - ] + "transaction_mechanism": { + "available": [ + "private_auction" + ] + }, + "has_requires": false } ] } diff --git a/examples/proposal.yaml b/examples/proposal.yaml index 94a6922..5b48c32 100644 --- a/examples/proposal.yaml +++ b/examples/proposal.yaml @@ -7,8 +7,9 @@ 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" -``` +| 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`. -- 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. +| 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 seller MAY publish `https:///.well-known/openproposal.json`, a +`ProposalManifest`. + +**`ProposalManifest`** keys: -Contents: `{ seller_id, generated_at, spec_version, proposals[] }`, where each -entry in `proposals[]` carries `proposal_id`, `version`, the Summary -Representation (§ 2.1) of each line item, and a `full_url` for the second -phase. +| 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 @@ -130,8 +257,6 @@ phase. not. - Manifest entries MUST match the current version of each proposal. A stale entry is a seller error. -- Property lists stay as names in the summary; large lists use `catalog_ref` - (§ 2.2). Two-phase flow: @@ -140,6 +265,76 @@ Two-phase flow: 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`. + --- ## 3. Field mutability @@ -150,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 @@ -167,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`. @@ -208,31 +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 | -| `linked_execution` | Enum | seller-set | `none` (default, subsettable) \| `all` (every published line item must be committed together) | +| 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 | --- @@ -243,11 +443,11 @@ 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`, each defined in 5.1.1. 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 @@ -272,6 +472,10 @@ served. | `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. @@ -279,27 +483,25 @@ name followed by its code. 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. -- A consumer MUST skip a line item whose `channel` it does not recognise, and - MUST NOT reject the proposal because of it. - Impressions in linear TV, including addressable linear, are outside this - table: they have no `channel` value in 3.0. + 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`, each defined in 5.2.1 | +| 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 -Each environment 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. +**`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. -| Value | Distribution channel | Device types | +| `environments` | Distribution channel | Device types | | :--- | :--- | :--- | | `web_desktop` | `Site` | Personal Computer (2) | | `web_mobile` | `Site` | Mobile/Tablet - General (1), Phone (4), Tablet (5) | @@ -312,27 +514,64 @@ 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 | -#### Creative inspection and previews +**`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 @@ -373,65 +612,81 @@ 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 | -| `requires` | Object | seller-set | OPTIONAL. Conditions that must hold in the same commitment for this line item to be committable. See 5.6.3 | +| 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 @@ -439,21 +694,23 @@ Transitions: `none` → `requested` → `held` → (`expired` | `released` | #### 5.6.3 requires -| Key | Meaning | -| :--- | :--- | -| `all_of[]` | Line items that MUST all be in the same commitment | -| `any_of[]` | Line items of which at least one MUST be in the same commitment | -| `min_commitment[]` | Purchase-level thresholds: `{ line_item_id, basis, amount, currency }` | +**`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_commitment: + 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 +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. @@ -471,41 +728,62 @@ self-contained. ### 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 | --- @@ -515,19 +793,20 @@ 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 section 5.4. +Inspecting this line item's VAST creative is subject to § 5.4.1. ### 6.4 Audio line item @@ -552,15 +831,15 @@ display line item is `registry_gated`, so it is not listed. See - **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 @@ -576,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 @@ -585,97 +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 | -| `linked_execution` | 4.1 Identity and lifecycle | Enum | seller-set | -| `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 | -| `requires` | 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 | --- @@ -683,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 | 31 | Seller sets and may revise while `draft` or `published`. | `valid_from` · `valid_until`, `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 | 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 73 fields, **23 are touched by the buyer** — 12 `selectable`, 5 `settable`, 3 -`supplied` and 3 `requestable`. The remaining 50 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. From a4b458480533df0ac2f4b16e9b446d80e51e1d6e Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Tue, 3 Nov 2026 23:56:47 +0100 Subject: [PATCH 30/47] Add errors, extensions, and forward compatibility Add section 2.4: a rejection returns errors[], one or more ErrorObjects with an open code list, retry values that read for any request, and informative HTTP fits. Name the code each rejection in sections 5.5, 5.6.1, and 5.6.3 returns, and drop the requires cycle ban. Add the Ext type and section 2.5: ext on the proposal and each line item, keyed by owner domain, ignored and kept when not understood, and frozen at agreed. Make spec_version a required proposal field, and ask in section 8 how a receiver reads a later release. Add informative security considerations in section 2.7. Word the open questions without a count. Update the examples and appendices, and teach check_examples.py the Ext type and the open ErrorObject. --- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/labels.yml | 2 +- CONTRIBUTING.md | 4 +- docs/open-questions.md | 19 +++- docs/repo-setup.md | 4 +- examples/line-item-display.yaml | 3 + examples/proposal.yaml | 4 + scripts/check_appendix.py | 1 + scripts/check_examples.py | 6 +- spec/CHANGELOG.md | 17 ++++ spec/openproposal-3.0.md | 148 ++++++++++++++++++++++++++---- 11 files changed, 182 insertions(+), 28 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 83f6576..568faef 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -8,6 +8,6 @@ contact_links: - name: Read the comment process first url: https://github.com/IABTechLab/OpenProposal/blob/main/docs/comment-process.md about: Timeline, dispositions, and what happens to your comment. - - name: The seven open questions + - name: The open questions url: https://github.com/IABTechLab/OpenProposal/blob/main/docs/open-questions.md about: Where the working group is genuinely undecided. Highest-value place to comment. diff --git a/.github/labels.yml b/.github/labels.yml index f3a7023..bee9d93 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -10,7 +10,7 @@ description: Proposes specific normative wording - name: open-question color: "d93f0b" - description: Relates to one of the 7 open questions in § 8 + description: Relates to one of the open questions in § 8 - name: implementation-feedback color: "0e8a16" description: From someone who built against the draft diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 920fa44..b3e2522 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ GitHub account may file an issue or a pull request. thread rather than opening a duplicate, so the disposition covers one discussion, not five. - Check [docs/open-questions.md](docs/open-questions.md). If your point lands on - one of the seven questions the draft explicitly asks, comment there. + one of the questions the draft explicitly asks, comment there. ## 2. Pick the right route @@ -24,7 +24,7 @@ GitHub account may file an issue or a pull request. | :--- | :--- | :--- | | **Comment** issue | Something is unclear, wrong, internally inconsistent, or missing. You are not proposing wording. | Triaged, dispositioned, logged | | **Change request** issue | You are proposing specific normative wording, a new field, a changed marker, or a removal. | Triaged, dispositioned, logged | -| **Open question** comment | You are answering one of the 7 questions in § 8. | Feeds the resolution of that question | +| **Open question** comment | You are answering one of the questions in § 8. | Feeds the resolution of that question | | **Implementation feedback** issue | You built against the draft and hit something. **This is the most valuable kind of comment we receive.** | Triaged with priority | | **Pull request** | You want to supply the exact diff for a change already discussed. | Reviewed against its issue | | **Discussion** | You have a question, or want to think out loud before filing. | No disposition; not part of the formal record | diff --git a/docs/open-questions.md b/docs/open-questions.md index c451d9e..67e887e 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -1,7 +1,7 @@ # Open questions -The draft asks seven questions it does not answer. These are the highest-value -places to comment, because the working group is genuinely undecided. +These are the questions the draft asks and does not answer. They are the +highest-value places to comment, because the working group is genuinely undecided. Each has its own tracking issue, labelled [`open-question`](https://github.com/IABTechLab/OpenProposal/issues?q=label%3Aopen-question). @@ -103,9 +103,22 @@ reporting what does not fit. --- +### OQ-8 — How does a receiver read a document from a later release? + +**Issue:** [#131](https://github.com/IABTechLab/OpenProposal/issues/131) · **Spec:** § 2.5, § 4.1 + +`spec_version` names the release a document follows, and § 2.5 makes a key the +receiver's release does not list invalid. Nothing says whether a receiver rejects a +release it does not know, or keeps and ignores keys it does not list. + +**What would settle it:** how an implementer upgrades a seller and its buyers when +they don't move at the same time, and whether unread keys can reach an assent. + +--- + ## Not on this list but worth saying If you think the draft has a problem it has not asked about, file it as a [Comment](https://github.com/IABTechLab/OpenProposal/issues/new?template=comment.yml) or a -[Change request](https://github.com/IABTechLab/OpenProposal/issues/new?template=change-request.yml). The seven +[Change request](https://github.com/IABTechLab/OpenProposal/issues/new?template=change-request.yml). The questions above are where we know we are undecided, not the limit of what we will consider. diff --git a/docs/repo-setup.md b/docs/repo-setup.md index e83bfa7..629e8d5 100644 --- a/docs/repo-setup.md +++ b/docs/repo-setup.md @@ -52,7 +52,7 @@ unchecked are outstanding. ## Tracking issues -- [ ] **File the seven open-question issues** and fill the links in +- [ ] **File the open-question issues** and fill the links in [`open-questions.md`](open-questions.md). Label each `open-question` and pin them. These are where you most want comments to land, so they need to exist before anyone arrives. @@ -82,7 +82,7 @@ were closed. - [ ] State the closing date. - [ ] State explicitly that non-members may comment — otherwise most of the market assumes it cannot. -- [ ] Point at the seven open questions, not just the spec. Asking a specific +- [ ] Point at the open questions, not just the spec. Asking a specific question gets a specific answer; "thoughts welcome" gets silence. ## During the period diff --git a/examples/line-item-display.yaml b/examples/line-item-display.yaml index 9da655e..5ca9276 100644 --- a/examples/line-item-display.yaml +++ b/examples/line-item-display.yaml @@ -108,3 +108,6 @@ buyer_requirements: { seat_required: true, dsp_allowed: ["dsp-a", "dsp-b"], seat_owner_entity: "Agency A" } correlation_id: "ep-q4-2026-commitment/business-daily-display" + +ext: + examplepub.example: { placement_code: "BD-ROS-300x250-Q4" } diff --git a/examples/proposal.yaml b/examples/proposal.yaml index 5b48c32..743f8aa 100644 --- a/examples/proposal.yaml +++ b/examples/proposal.yaml @@ -2,6 +2,7 @@ # Illustrative only. Seller names and rates are fictional. proposal_id: "examplepub-us-q4-2026-omni" version: 1 +spec_version: "3.0" seller_id: "reg:examplepub-digital" seller_name: "Example Publisher US" type: standard @@ -21,3 +22,6 @@ specifications: > best_for: ["B2B", "financial services", "high-income reach"] not_suitable_for: ["youth", "endemic gaming"] seasonality_notes: "Earnings weeks carry materially higher engagement." + +ext: + examplepub.example: { package_code: "OMNI-Q4-2026" } diff --git a/scripts/check_appendix.py b/scripts/check_appendix.py index 5b8c8ce..a02079a 100755 --- a/scripts/check_appendix.py +++ b/scripts/check_appendix.py @@ -43,6 +43,7 @@ ("Type", "Form", "Rules"), ("Marker", "Meaning"), # Value tables (section 2.6): the first header cell names the key that holds the list. + ("`code`", "Returned when"), ("`channel`", "Condition"), ("`environments`", "Distribution channel", "Device types"), # Other tables state rules as prose does (section 2.6). diff --git a/scripts/check_examples.py b/scripts/check_examples.py index d31b0fb..5145e8b 100755 --- a/scripts/check_examples.py +++ b/scripts/check_examples.py @@ -32,7 +32,7 @@ EXAMPLES = ROOT / "examples" # Object types whose extra keys are allowed. -OPEN_TYPES = {"DocumentRef"} +OPEN_TYPES = {"DocumentRef", "ErrorObject"} # A named type that holds the keys of another. COMPOSES = {"Commitment": ["CommitmentAmount"], "ManifestEntry": ["ProposalSummary"]} @@ -97,6 +97,10 @@ "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("/"))), + "Ext": (lambda v: isinstance(v, dict) and all( + isinstance(k, str) and DOMAIN.match(k) and isinstance(item, dict) + for k, item in v.items() + )), } diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 2027abe..6a809ec 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,6 +5,23 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] +- § 2, § 2.2, § 2.4, § 2.5, § 2.7, § 4.1, § 5.1, § 5.5, § 5.6.1, § 5.6.3, § 8, + appendices, examples, docs, checker — errors, extensions, and forward + compatibility. A rejection returns `errors[]`, one or more `ErrorObject`s with + `code`, `detail`, `pointer`, `version`, and `retry`. The core codes include + `not_eligible` and `record_mismatch`, and anyone can add a code under a domain + they control. An `ErrorObject` is open, and `validation` applies when no other code + does. `idempotency_conflict` tests the body, and an error with `retry: same` is not + a stored result. § 5.5, § 5.6.1, and § 5.6.3 name the code each rejection returns. + The proposal and each line item MAY carry `ext`, an `Ext` keyed by owner domain. A + receiver ignores an entry it doesn't understand and keeps it unchanged, and `ext` + is frozen at `agreed`. A key outside `ext` that a key table doesn't list is invalid. + `spec_version` is a REQUIRED proposal field, and § 8 asks how a receiver reads a + later release. § 2.7 adds informative security considerations. The `requires` + cycle ban is removed. Breaking + ([#130](https://github.com/IABTechLab/OpenProposal/issues/130), + [#131](https://github.com/IABTechLab/OpenProposal/issues/131), + [#33](https://github.com/IABTechLab/OpenProposal/issues/33)). - § 2, § 3, § 4, § 5, § 7, § 8, appendices, examples, docs, checker — shared types and authoring rules. § 2.2 gives each repeated concept one type, with one rule for an unknown vocabulary term. § 2.6 states the table, presence, type, key, and value diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index ebe1b7e..aea5d55 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -62,8 +62,8 @@ line_items[] → buyable units, each in exactly one channel **No-inheritance rule.** A field on a line item MUST NOT inherit its value from the proposal or from a sibling line item. There is no "overridable unless restated" semantics anywhere in this specification. Every term of a line item is -on the line item. The proposal supplies only identity, the offer window, -`version`, and linked execution. +on the line item. The proposal supplies only identity, `spec_version`, the offer +window, `version`, and linked execution. **Subsettable proposals.** A buyer MAY select and commit a subset of a proposal's line items, unless the proposal sets `linked_execution` to `all` @@ -162,6 +162,7 @@ shared type or changes its form. | `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 | +| `Ext` | `Map[Domain, Object]` | The extension container. § 2.5 states its rules | **`VocabularyTerm` rules.** A field that takes a `VocabularyTerm` lists its core terms. Anyone MAY define an extension term. Terms compare byte for byte. A @@ -265,6 +266,79 @@ Two-phase flow: 2. **Propose:** fetch the Full Representation of shortlisted line items from `full_url`, then compose and commit as usual. +### 2.4 Errors + +A party that rejects a request MUST return `errors[]`, one or more +`ErrorObject`s. This specification defines the object. The binding that carries +the request carries the errors. + +A seller's decline of a resolved `commitment`, or of a hold, answers a valid +request and is not an error. What a party answers when request authentication +fails is the AAMP agent identity and signing profile's to state. + +**`ErrorObject`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `code` | VocabularyTerm | REQUIRED | A core code (below) or an extension code | +| `detail` | String | REQUIRED | Human-readable. A receiver SHOULD NOT parse it | +| `pointer` | FieldPointer | OPTIONAL | Into the object this specification defines that the request carries, to the value that caused the error | +| `version` | Integer | REQUIRED when the request names a proposal that exists | The proposal's `version` when the error was decided | +| `retry` | Enum | REQUIRED | `same` \| `refetch` \| `none`. `same`: the same request can succeed later. `refetch`: a new attempt, composed on the current `version`, can succeed. `none`: only a changed request can succeed. On a commitment attempt, `same` keeps the `idempotency_key` and such an error is not a stored result (§ 5.6.1), and `refetch` and `none` take a new `idempotency_key` | + +Core codes: `validation` \| `not_found` \| `not_offered` \| `unresolved_reference` \| +`not_committable` \| `stale_version` \| `expired` \| `idempotency_conflict` \| +`not_eligible` \| `record_mismatch`. + +| `code` | Returned when | +| :--- | :--- | +| `validation` | A value breaks a rule of this specification and no other code applies | +| `not_found` | A named proposal or line item does not exist | +| `not_offered` | A buyer value is outside what the seller declared (§ 3.1, § 3.2, § 5.5) | +| `unresolved_reference` | A `DocumentRef` (§ 2.2) does not resolve, or its content does not match its `digest` | +| `not_committable` | A line item's `status`, or a `requires` that is unmet (§ 5.6.3), rules out commitment | +| `stale_version` | The commitment was composed on terms that are not the current terms (§ 5.6.1) | +| `expired` | `offer_window.end` or `price_valid_until` has passed | +| `idempotency_conflict` | An `idempotency_key` is reused with a different body | +| `not_eligible` | The buyer or the sender fails an eligibility or authority check the seller applies (§ 5.8) | +| `record_mismatch` | A `record_digest` in the request does not equal the one the receiver recomputes (§ 4.1.1) | + +An `ErrorObject` is open. A receiver that does not recognize a code, or a key of +the object, acts on `retry`. + +Informative: over HTTP, `validation`, `not_offered`, and +`unresolved_reference` fit 400 Bad Request; `not_eligible` 403 Forbidden; +`not_found` 404 Not Found; `not_committable`, `stale_version`, and +`record_mismatch` 409 Conflict; `expired` 410 Gone; `idempotency_conflict` 422 +Unprocessable Content. An error with `retry: same` fits 503 Service Unavailable. + +### 2.5 Extensions + +The proposal and each line item MAY carry +`ext`, an `Ext`. Each key MUST be a `Domain` that its owner controls, and each value +is an object that the owner defines. + +```yaml +ext: + examplepub.example: { placement_code: "BD-ROS-300x250-Q4" } +``` + +- An `ext` entry MUST NOT change the meaning of a field this specification + defines. A term the counterparty must understand to transact MUST NOT be an + `ext` entry; it is a field or a vocabulary term. +- A receiver MUST ignore an `ext` entry it does not understand, and MUST NOT + reject a document because of it. It MUST keep `ext` unchanged when it + stores or forwards the document. +- In an object whose keys this specification lists, a key outside `ext` that + it does not list is invalid. The open objects `DocumentRef` (§ 2.2) and + `ErrorObject` (§ 2.4) are unaffected. +- On the proposal and a line item, `ext` is part of the Full Representation and + of the record frozen at `agreed`, and a seller change to it is a + revision. +- Apart from an unknown term (§ 2.2), a receiver MAY skip a whole line item it + cannot read, without assenting to it. It MUST NOT keep a line item after removing + fields from it. + ### 2.6 Table and naming conventions **Tables.** A field table has the columns Field, Type, Required, Mutability, and @@ -335,6 +409,34 @@ required. Otherwise, absence means none. 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`. +### 2.7 Security considerations + +This section is informative. It sets no requirement on a party to a transaction. + +**Text is data.** Agents that can spend read this object, and much of it is text +the counterparty wrote: `description`, `specifications`, `best_for[]`, +`not_suitable_for[]`, `seasonality_notes`, `approval`, `creative_policy`, `ext` values, +an error's `detail`, and the document behind a +`DocumentRef`. That text is data, and it can hold instructions aimed at a language +model. An agent keeps it apart from its own instructions, and rests a decision to +assent or spend on the structured fields, not on prose alone. + +**URLs.** A document names URLs its author chose: `proposals[].full_url`, +a `DocumentRef`'s `url`, an `Asset`'s `url`, and a `CreativeDelivery`'s `endpoint`. Fetching one lets the +author choose where a client connects. A client uses `https`, bounds the size of a +response and the number of redirects, and refuses an address inside its own +network, on every redirect as well. § 5.4.1 covers what inspecting a creative may +request. + +**Digests.** A `digest` shows that the bytes are the ones a `DocumentRef` names. It +does not show who wrote them, or that they are safe to act on. A client compares +it before reading the bytes, and `unresolved_reference` (§ 2.4) reports a mismatch. + +**Buyer lists.** `exclusions`, `buyer_supplied.segments[]`, and +`buyer_supplied.suppression_list` show a buyer's strategy and can hold data about +people. A party that holds them limits who can read them, in stored records and +logs as well as in responses. § 5.8 states the disclosure rule for `exclusions`. + --- ## 3. Field mutability @@ -412,6 +514,7 @@ items. | :--- | :--- | :--- | :--- | :--- | | `proposal_id` | String | REQUIRED | immutable | Unique identity for the proposal object | | `version` | Integer | REQUIRED | derived | Monotonically increasing revision number. Each revision of the proposal increments it | +| `spec_version` | String | REQUIRED | seller-set | The version of this specification the document follows, such as `3.0` | | `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` | @@ -422,6 +525,7 @@ items. | `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) | +| `ext` | Ext | OPTIONAL | seller-set | See Extensions (§ 2.5) | ### 4.2 Agent comprehension @@ -449,6 +553,7 @@ channel. | `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 | +| `ext` | Ext | OPTIONAL | seller-set | See Extensions (§ 2.5) | #### 5.1.1 channel @@ -650,12 +755,12 @@ exclusions: 2.6 `app.bundle` value; `platform` names the store (`ios` \| `android` \| `roku` \| `fire_tv` \| `samsung_tv` and other store platforms). A seller MUST reject an entry whose platform it cannot determine, rather than apply it - across stores. + across stores, with `validation`. - `iab_content_category[]` entries are IAB Content Taxonomy ids. - `enforcement` is `pre_serve` or `best_effort` per type. - A buyer MUST NOT supply an exclusion type absent from `exclusions_accepted.types`. A seller MUST reject it deterministically - rather than ignore it. + rather than ignore it, with `not_offered`. - A seller MUST NOT present `best_effort` enforcement as a guarantee. - `list_ref` MUST be hash-pinned, and resolved into the stored record at `agreed`. @@ -719,20 +824,21 @@ seller MUST NOT interpret it. The seller MUST reject a new commitment attempt whose `version` is not the current version of the proposal named by `proposal_id`, whose proposal is past `offer_window.end`, or where the `pricing[]` entry the commitment is priced against -is past its `price_valid_until`. The rejection MUST return the current version -number, so the buyer can fetch it again. These checks MUST be deterministic: -they depend only on the stored proposal and the time the seller receives the -attempt. +is past its `price_valid_until`. The rejection MUST carry `stale_version`, or +`expired` when `offer_window.end` or `price_valid_until` has passed, so the buyer can +fetch it again. These checks MUST be deterministic: they depend only on the +stored proposal and the time the seller receives the attempt. For a replay from the same buyer of a commitment attempt the seller has already processed, with the same `idempotency_key` and the same body (every value the buyer sent with the attempt except `idempotency_key`), the seller MUST return the original result, whether an acceptance or a rejection, and MUST NOT create a second commitment. The version and expiry checks apply to a -new attempt, not to returning a stored result. Reusing an `idempotency_key` -with a different body MUST produce an explicit conflict and MUST NOT create or -modify a commitment. A retry against a new `version` is a new attempt, with a -new `idempotency_key`. +new attempt, not to returning a stored result. An error whose `retry` is `same` +is not a stored result, so the retry runs the checks again. Reusing an +`idempotency_key` with a different body MUST return `idempotency_conflict` and +MUST NOT create or modify a commitment. A retry against a new `version` is a +new attempt, with a new `idempotency_key`. #### 5.6.2 hold_status @@ -776,13 +882,12 @@ 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. + is unmet. An unmet `requires` returns `not_committable`. 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 @@ -934,6 +1039,10 @@ see [docs/open-questions.md](../docs/open-questions.md). 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. +8. **How does a receiver read a document from a later release?** `spec_version` + names the release a document follows, and § 2.5 makes a key the receiver's + release does not list invalid. Nothing says whether a receiver rejects a release + it does not know, or keeps and ignores keys it does not list. --- @@ -947,6 +1056,7 @@ body is normative.** | :--- | :--- | :--- | :--- | :--- | | `proposal_id` | 4.1 Identity and lifecycle | String | REQUIRED | immutable | | `version` | 4.1 Identity and lifecycle | Integer | REQUIRED | derived | +| `spec_version` | 4.1 Identity and lifecycle | String | REQUIRED | seller-set | | `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 | @@ -957,6 +1067,7 @@ body is normative.** | `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 | +| `ext` | 4.1 Identity and lifecycle | Ext | 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 | @@ -965,6 +1076,7 @@ body is normative.** | `line_item_id` | 5.1 Identity | String | REQUIRED | immutable | | `status` | 5.1 Identity | Enum | REQUIRED | lifecycle | | `channel` | 5.1 Identity | MediaChannel | REQUIRED | immutable | +| `ext` | 5.1 Identity | Ext | OPTIONAL | seller-set | | `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 | @@ -1029,7 +1141,7 @@ body is normative.** | Marker | Count | Meaning | Fields | | :--- | :--- | :--- | :--- | | 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 | 33 | 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`, `exclusions_accepted`, `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[]` | +| seller-set | 36 | The seller sets it and MAY revise it while status is `draft` or `published`. The buyer cannot change it directly. | `spec_version`, `offer_window`, `line_items[]`, `linked_execution`, `ext` (proposal), `description`, `specifications`, `best_for[]`, `not_suitable_for[]`, `seasonality_notes`, `ext` (line item), `content_detail`, `specs`, `third_party_tags`, `creative_delivery`, `approval`, `exclusions_accepted`, `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` | | supplied | 1 | The buyer provides data or an artifact. The seller does not pre-populate it. | `exclusions` | @@ -1042,8 +1154,8 @@ body is normative.** ### What the distribution says -Of 76 fields, **20 are touched by the buyer** — 12 `selectable`, 5 `settable`, 1 -`supplied` and 2 `requestable`. The remaining 56 are `seller-set`, `immutable`, +Of 79 fields, **20 are touched by the buyer** — 12 `selectable`, 5 `settable`, 1 +`supplied` and 2 `requestable`. The remaining 59 are `seller-set`, `immutable`, `derived`, `lifecycle`, `recorded`, `asserted` or `mixed`. Only the 2 `requestable` fields require a round trip to resolve (§ 5.6.1). From 0b204045a85b40c03ba4c4c9d5f2d69b56f33352 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Wed, 4 Nov 2026 00:14:21 +0100 Subject: [PATCH 31/47] Name the parties and what each identity layer proves Make seller a Party in section 4.1, with a required legal_name, and add buyer and commitment.advertisers[]. Move the registry_id rules to Party in section 2.2, and scope proposal_id to seller.domain. Add section 1.2 with the three identity layers. In section 5.8, require visibility, define each value, and separate who may read from who may commit. Eligibility is a resolve step that returns not_eligible, and an unreadable line item returns not_found. A required credential is { credential_type, issuer }, and eligible_bidders[] splits into eligible_buyers[] and eligible_seats[]. Give buyer_requirements, billing, and agreement key tables, and convert the rows the terms and agreement change added. In section 2.3, drop the manifest seller_id and filter on status, and ask in section 8 whether a manifest needs both validators. Update the examples, appendices, and checkers. --- docs/open-questions.md | 12 ++ examples/line-item-display.yaml | 15 ++- examples/manifest.json | 6 +- examples/proposal.yaml | 4 +- scripts/check_appendix.py | 5 +- scripts/check_examples.py | 17 ++- spec/CHANGELOG.md | 34 ++++++ spec/openproposal-3.0.md | 209 ++++++++++++++++++++++++++------ 8 files changed, 248 insertions(+), 54 deletions(-) diff --git a/docs/open-questions.md b/docs/open-questions.md index 67e887e..4d344b7 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -116,6 +116,18 @@ they don't move at the same time, and whether unread keys can reach an assent. --- +### OQ-11 — Must a manifest response carry both `ETag` and `Last-Modified`? + +**Issue:** [#29](https://github.com/IABTechLab/OpenProposal/issues/29) · **Spec:** § 2.3 + +The draft requires at least one, so a buyer's client handles whichever the seller +sends. + +**What would settle it:** which validator polling clients use, and what the hosts +that serve the manifest send by default. + +--- + ## Not on this list but worth saying If you think the draft has a problem it has not asked about, file it as a diff --git a/examples/line-item-display.yaml b/examples/line-item-display.yaml index be78474..f549a1e 100644 --- a/examples/line-item-display.yaml +++ b/examples/line-item-display.yaml @@ -76,6 +76,7 @@ commitment: amount: 80000.00 currency: USD flight: { start: "2026-10-06T00:00:00Z", end: "2026-12-21T23:59:59Z" } + advertisers: [{ domain: "brand-a.example" }] derived_units: 3333333 # derived: 80000.00 / 24.00 × 1000 idempotency_key: "agency-a-camp1-business-daily-display-8f42c1a9" @@ -96,8 +97,7 @@ reporting: deltas: { log_level: { delta_flat: 2500.00 } } visibility: registry_gated -required_credentials: [{ type: dsp_seat, scheme: jwt_keypair, - issuer: "dsp-a", id_required: true }] +required_credentials: [{ credential_type: iab_registry, issuer: "registry.iabtechlab.ai" }] terms_acceptance_required: true terms_ref: { url: "https://examplepub.example/legal/io-terms-2026.pdf", sha256: "", version: "2026.1" } @@ -106,9 +106,14 @@ buying_route: PUBLISHER_AD_SERVER 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", - seat_owner_entity: "Agency A" } +buyer_requirements: { seats_expected: true, + allowed_dsps: ["dsp-a.example", "dsp-b.example"], + seats: { dsp: "dsp-a.example", ids: ["seat-99812"] }, # the buyer's write + seat_owner: { domain: "agency-a.example", legal_name: "Agency A LLC" } } +billing: { invoicing_party: { domain: "examplepub-billing.example", + legal_name: "Example Publisher Billing LLC" }, + payment_terms: "net 30", invoice_period: month, + time_zone: "America/New_York" } correlation_id: "ep-q4-2026-commitment/business-daily-display" diff --git a/examples/manifest.json b/examples/manifest.json index 7e45029..981e456 100644 --- a/examples/manifest.json +++ b/examples/manifest.json @@ -1,12 +1,14 @@ { - "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", + "seller": { + "domain": "examplepub.example", + "legal_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", diff --git a/examples/proposal.yaml b/examples/proposal.yaml index 743f8aa..319197a 100644 --- a/examples/proposal.yaml +++ b/examples/proposal.yaml @@ -3,8 +3,8 @@ proposal_id: "examplepub-us-q4-2026-omni" version: 1 spec_version: "3.0" -seller_id: "reg:examplepub-digital" -seller_name: "Example Publisher US" +seller: { domain: "examplepub.example", legal_name: "Example Publisher US", + registry_id: "reg:examplepub-digital" } # OPTIONAL: an attestation (§ 2.2) type: standard linked_execution: none # line items may be bought separately (§ 4.1) status: published diff --git a/scripts/check_appendix.py b/scripts/check_appendix.py index a02079a..520d447 100755 --- a/scripts/check_appendix.py +++ b/scripts/check_appendix.py @@ -48,6 +48,7 @@ ("`environments`", "Distribution channel", "Device types"), # Other tables state rules as prose does (section 2.6). ("OpenProposal concept", "Downstream standard"), + ("Layer", "Question", "Answered by", "Owner", "In the proposal object"), # Generated. ("Field", "Section", "Type", "Required", "Marker"), ("Marker", "Count", "Meaning", "Fields"), @@ -70,7 +71,6 @@ "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", } @@ -88,8 +88,7 @@ "`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", + "`terms_ref`": "waiting: the binding document reference", } BODY_SECTION = re.compile(r"^#{3,4} ((?:4|5)\.\d+(?:\.\d+)?)\s") diff --git a/scripts/check_examples.py b/scripts/check_examples.py index 5145e8b..3e84dc7 100755 --- a/scripts/check_examples.py +++ b/scripts/check_examples.py @@ -12,7 +12,8 @@ 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). + each public, published line item (section 2.3). + - seller and buyer on a proposal hold legal_name (section 4.1). KNOWN holds findings that a later change removes. It only shrinks. Run with --regen-manifest to rewrite examples/manifest.json from the full examples. @@ -51,6 +52,10 @@ # Each `REQUIRED when ` wording, as (object, line item, proposal) -> bool. CONDITIONS = { "`type` is `custom`": lambda o, i, p: o.get("type") == "custom", + "`assent` is present": lambda o, i, p: "assent" in o, + "`invoice_period` is `cycle`": lambda o, i, p: o.get("invoice_period") == "cycle", + "`terms_acceptance_required` is `true`": + lambda o, i, p: i.get("terms_acceptance_required") is True, "`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, @@ -403,6 +408,9 @@ def check_fields(self, file, path, doc, fields): else: self.proposal = doc self.item = {} + for key in ("seller", "buyer"): + if key in doc and "legal_name" not in doc[key]: + self.err(file, key, "legal_name", "legal_name is REQUIRED here") for key in doc: if key not in known: self.err(file, path or "(root)", key, f"key {key!r} is not in a field table") @@ -478,16 +486,15 @@ def proposal_summary(proposal, spec): def build_manifest(spec): - """Section 2.3: the proposal's public line items, as summaries.""" + """Section 2.3: the proposal's public, published line items, as summaries.""" proposal = load_yaml(EXAMPLES / "proposal.yaml") entry = proposal_summary(proposal, spec) - entry["full_url"] = f"https://examplepub.example/openproposal/{proposal['proposal_id']}" + entry["full_url"] = f"https://{proposal['seller']['domain']}/openproposal/{proposal['proposal_id']}" entry["line_items"] = [ line_item_summary(item, spec) for item in full_examples().values() - if item.get("visibility") == "public" + if item.get("visibility") == "public" and item.get("status") == "published" ] return { - "seller_id": proposal["seller_id"], "generated_at": "2026-10-03T15:00:00Z", "spec_version": "3.0", "proposals": [entry], diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index a738b48..1f55c73 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,6 +5,40 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] +- § 1.2, § 2.1 to § 2.5, § 4.1, § 5.6.1, § 5.8, § 5.9, § 5.10, § 7, § 8, appendices, + examples, docs, checker — who the parties are, and what each identity layer proves. + `seller_id` and `seller_name` become `seller`, a `Party` whose `legal_name` is + REQUIRED, and the registry reference becomes `seller.registry_id`. `buyer` and + `commitment.advertisers[]` are added. § 1.2 states who owns request authentication, + authorization, and legal assent. § 5.8 defines each `visibility` value, which is + REQUIRED, and separates who may read from who may commit. Eligibility is part of + resolving a `commitment`; a failure returns `not_eligible`, and an unreadable line + item returns `not_found`. `required_credentials[]` entries become + `{ credential_type, issuer }`: `scheme`, its token list, and `id_required` are + removed, and so are the `dsp_seat` and `custom` types. `eligible_bidders[]` splits + into `eligible_buyers[]` and `eligible_seats[]`. `dsp_id` and `dsp_seat_id` become + `buyer_requirements.seats`, a `SeatRef`; `seat_required` becomes `seats_expected`, + `dsp_allowed[]` becomes `allowed_dsps[]`, and `seat_owner_entity` becomes + `seat_owner`. `billing.supplier_id` and `billing.billing_supplier_id` become + `billing.delivering_party`, and `billing` adds `invoice_period`, `cycle_length`, and + `time_zone`. `agreement_ref` becomes `agreement`, with `agreement_type` and `term`, + and its `parties[]` holds `Party` values. The manifest drops `seller_id`, filters on + `status` as well as `visibility`, and needs at least one of `ETag` and + `Last-Modified`; § 8 asks whether it needs both. A buyer discards a manifest entry + whose `seller.domain` differs from the requested domain, and `full_url` returns only + what the requester may read. A full fetch leaves out line items the requester may + not read, and `ErrorObject.version` applies to a proposal the requester may read. + `proposal_id` is unique within `seller.domain`, and a seller's own `ext` key is its + `seller.domain`. `agreement_ref.valid_from` and `valid_until` become + `agreement.term`, `agreement` adds `document`, and `external_references[].role` is a + `VocabularyTerm`. Breaking + ([#61](https://github.com/IABTechLab/OpenProposal/issues/61), + [#62](https://github.com/IABTechLab/OpenProposal/issues/62), + [#132](https://github.com/IABTechLab/OpenProposal/issues/132), + [#126](https://github.com/IABTechLab/OpenProposal/issues/126), + [#57](https://github.com/IABTechLab/OpenProposal/issues/57), + [#29](https://github.com/IABTechLab/OpenProposal/issues/29), + [#27](https://github.com/IABTechLab/OpenProposal/issues/27)). - § 2, § 2.2, § 2.4, § 2.5, § 2.7, § 4.1, § 5.1, § 5.5, § 5.6.1, § 5.6.3, § 8, appendices, examples, docs, checker — errors, extensions, and forward compatibility. A rejection returns `errors[]`, one or more `ErrorObject`s with diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index fdb5d97..bb0694e 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -50,6 +50,22 @@ lands in the one its `transaction_mechanism` maps to: | PMP deal | Deals API + OpenRTB 2.6 · Deal | | Open market | OpenRTB 2.6 · open auction, no deal | +### 1.2 Relationship to other AAMP artifacts + +**Identity.** Three questions about a party stay separate, for the buyer and the +seller alike. An answer to one is not an answer to another. + +| Layer | Question | Answered by | Owner | In the proposal object | +| :--- | :--- | :--- | :--- | :--- | +| Request authentication | Which origin sent this request? | An [HTTP Message Signature](https://www.rfc-editor.org/rfc/rfc9421), with the key in that origin's [Web Bot Auth](https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/) directory | The AAMP agent identity and signing profile (provisional name) | Nothing. The object never carries or re-proves it | +| Authorization | What may this party, or its agent, do? | The seller's eligibility rules; a mandate the principal publishes or signs | This specification for the rules (§ 5.8); the AAMP authority document (provisional name) for mandates | The rules only, never the proof | +| Legal assent | Which legal entity agreed to which terms? | The `assent` records for the binding version, with both parties named in the proposal | This specification for the parties and the record (§ 4.1); the profile for any signature over it | Parties, `assent` | + +A request signature proves an `https` origin. It does not prove which party that +origin speaks for: a hosted agent's origin is the host's, and a mandate the party +publishes or signs connects them. What authenticates a seller's response is the +profile's to state. Reading a public file (§ 2.3) needs none of this. + --- ## 2. Object shape @@ -75,7 +91,8 @@ when its conditions are met. Every proposal and every line item has two representations. **Full Representation:** every field the object holds, with a proposal's line items -in their Full Representation. A full fetch MUST return it. +in their Full Representation. A full fetch MUST return it, except for line items the +requester may not read (§ 5.8). **Summary Representation:** what a buying agent reads when screening many sellers' shelves. A manifest entry holds it (§ 2.3), and a response MAY @@ -107,6 +124,7 @@ commitment_bounds addressable_scale committed_metrics[] required_credentials[] +buyer_requirements.allowed_dsps[] transaction_mechanism.available[] ``` @@ -121,7 +139,8 @@ transaction_mechanism.available[] ```text proposal_id version -seller_name +seller.domain +seller.legal_name description offer_window linked_execution @@ -185,7 +204,10 @@ retrievable, and the counterparty stores a copy. | `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. +**`VersionRef`** locates an offer at a version. It carries no digest. A `proposal_id` +is unique only within a `seller.domain` (§ 4.1): a `VersionRef` inside a proposal +resolves against that proposal's `seller`, and a system that holds one outside a +proposal keeps the `seller.domain` beside it. | Key | Type | Required | Meaning | | :--- | :--- | :--- | :--- | @@ -194,12 +216,15 @@ retrievable, and the counterparty stores a copy. | `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. +an attestation. An absent or unresolvable `registry_id` MUST NOT make a document +malformed, and a consumer MUST ignore a resolved entry whose verified domain is +not `domain`. The Agent Registry defines its reference format and how it exposes the +verified domain. | Key | Type | Required | Meaning | | :--- | :--- | :--- | :--- | | `domain` | RegistrableDomain | REQUIRED | The party's domain | -| `legal_name` | String | OPTIONAL | The party's legal name | +| `legal_name` | String | OPTIONAL | The party's legal name. A `registry_id` entry that applies can attest it; the value alone attests nothing | | `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. @@ -234,14 +259,13 @@ present, and `min` is no greater than `max`. ### 2.3 Publication -A seller MAY publish `https:///.well-known/openproposal.json`, a +A seller MAY publish `https:///.well-known/openproposal.json`, a `ProposalManifest`. **`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 | @@ -252,10 +276,15 @@ A seller MAY publish `https:///.well-known/openproposal.json`, a | :--- | :--- | :--- | :--- | | `full_url` | Url | REQUIRED | Where the second phase fetches the Full Representation | -- `ETag` / `Last-Modified` are REQUIRED, so buyers can poll cheaply. +- At least one of `ETag` and `Last-Modified` is REQUIRED, so buyers can poll cheaply. + § 8 asks whether both are. - Only standard proposals appear, and within them only line items whose - `visibility` (§ 5.8) is `public`. `registry_gated` and `named` line items do - not. + `visibility` (§ 5.8) is `public` and whose `status` is `published`. + `registry_gated` and `named` line items do not. +- A buyer discards an entry whose `seller.domain` differs from the domain in the + URL it requested, before any redirect. +- `full_url` returns only what the requester may read (§ 5.8), so a request with + no authenticated identity gets only `public` line items. - Manifest entries MUST match the current version of each proposal. A stale entry is a seller error. @@ -283,7 +312,7 @@ fails is the AAMP agent identity and signing profile's to state. | `code` | VocabularyTerm | REQUIRED | A core code (below) or an extension code | | `detail` | String | REQUIRED | Human-readable. A receiver SHOULD NOT parse it | | `pointer` | FieldPointer | OPTIONAL | Into the object this specification defines that the request carries, to the value that caused the error | -| `version` | Integer | REQUIRED when the request names a proposal that exists | The proposal's `version` when the error was decided | +| `version` | Integer | REQUIRED when the request names a proposal the requester may read | The proposal's `version` when the error was decided | | `retry` | Enum | REQUIRED | `same` \| `refetch` \| `none`. `same`: the same request can succeed later. `refetch`: a new attempt, composed on the current `version`, can succeed. `none`: only a changed request can succeed. On a commitment attempt, `same` keeps the `idempotency_key` and such an error is not a stored result (§ 5.6.1), and `refetch` and `none` take a new `idempotency_key` | Core codes: `validation` \| `not_found` \| `not_offered` \| `unresolved_reference` \| @@ -293,7 +322,7 @@ Core codes: `validation` \| `not_found` \| `not_offered` \| `unresolved_referenc | `code` | Returned when | | :--- | :--- | | `validation` | A value breaks a rule of this specification and no other code applies | -| `not_found` | A named proposal or line item does not exist | +| `not_found` | A named proposal or line item does not exist, or the requester may not read it (§ 5.8) | | `not_offered` | A buyer value is outside what the seller declared (§ 3.1, § 3.2, § 5.5) | | `unresolved_reference` | A `DocumentRef` (§ 2.2) does not resolve, or its content does not match its `digest` | | `not_committable` | A line item's `status`, or a `requires` that is unmet (§ 5.6.3), rules out commitment | @@ -316,7 +345,8 @@ Unprocessable Content. An error with `retry: same` fits 503 Service Unavailable. The proposal and each line item MAY carry `ext`, an `Ext`. Each key MUST be a `Domain` that its owner controls, and each value -is an object that the owner defines. +is an object that the owner defines. A seller's own entries MUST use its +`seller.domain` as the key. ```yaml ext: @@ -512,11 +542,11 @@ items. | Field | Type | Required | Mutability | Description | | :--- | :--- | :--- | :--- | :--- | -| `proposal_id` | String | REQUIRED | immutable | Unique identity for the proposal object | +| `proposal_id` | String | REQUIRED | immutable | Identifies the proposal. Unique within `seller.domain` | | `version` | Integer | REQUIRED | derived | Monotonically increasing revision number. Each revision of the proposal increments it | | `spec_version` | String | REQUIRED | seller-set | The version of this specification the document follows, such as `3.0` | -| `seller_id` | String | REQUIRED | immutable | IAB Tech Lab registry reference | -| `seller_name` | String | OPTIONAL | immutable | Canonical legal seller entity name, from the registry | +| `seller` | Party | REQUIRED | immutable | The selling entity. `seller.domain` is the seller's identifier for all purposes, including `assent`. `legal_name` is REQUIRED here | +| `buyer` | Party | REQUIRED when `assent` is present | immutable | The entity that assents, usually the agency. Absent until the seller issues the proposal to one buyer. Whom the spend is for is `commitment.advertisers[]` (§ 5.6.1), and whether the buyer may bind that advertiser is authorization (§ 1.2). `legal_name` is REQUIRED here | | `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 | @@ -527,6 +557,20 @@ items. | `linked_execution` | Enum | OPTIONAL | seller-set | `none` (default, subsettable) \| `all` (every published line item must be committed together) | | `ext` | Ext | OPTIONAL | seller-set | See Extensions (§ 2.5) | +**Seller identity.** A proposal names exactly one `seller.domain`. A seller MUST +NOT name a domain it does not control. For this specification one `seller.domain` +is one seller: entities that transact under one domain are one seller here, and +`seller.legal_name` carries the legal name. A selling entity that controls several +registrable domains SHOULD use the same `seller.domain` for every proposal it +publishes as one seller; a seller that publishes proposals under several domains +holds an Agent Registry entry for each, or the entry does not apply. The field is immutable, +so a seller that moves to another domain publishes new proposals. Where a third party +hosts a seller's agent or published files, `seller.domain` is the selling +entity's own registrable domain, never the host's. Continuity of a legal entity +across domains, rebrands, or acquisitions is a fact the Agent Registry attests; this +object does not encode it. Whether to deal with a seller that has no Agent +Registry entry is buyer policy. + ### 4.2 Agent comprehension The case a buying agent reads to decide whether to look further. @@ -797,7 +841,8 @@ exclusions: #### 5.6.1 commitment -Absent on a published standard proposal. Populated during composition. Frozen +Absent on a published standard proposal. Populated during composition. A +`commitment` resolves under § 3 only when the buyer is eligible (§ 5.8). Frozen when status becomes `agreed`. **`CommitmentAmount`** is the committed figure. `basis` means only this. @@ -815,6 +860,7 @@ when status becomes `agreed`. | `version` | Integer | REQUIRED | The proposal `version` the commitment was priced against | | `currency` | String | REQUIRED when `basis` is `spend` | ISO-4217 | | `flight` | Interval | REQUIRED | The buyer's campaign. **Distinct from `offer_window`** | +| `advertisers[]` | Array[Party] | OPTIONAL | Whom the spend is for | | `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 | @@ -918,11 +964,16 @@ self-contained. ### 5.8 Transaction eligibility +`visibility` says who may read a line item. `required_credentials[]`, +`eligible_buyers[]`, `eligible_seats[]`, and `buyer_requirements` (§ 5.9) say who +may commit to it. + | 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 | +| `visibility` | Enum | REQUIRED | seller-set | `public` \| `registry_gated` \| `named`. See below | +| `required_credentials[]` | Array[RequiredCredential] | OPTIONAL | seller-set | What a buyer holds to be eligible | +| `eligible_buyers[]` | Array[RegistrableDomain] | OPTIONAL | seller-set | The buyers that may commit, by `buyer.domain` | +| `eligible_seats[]` | Array[SeatRef] | OPTIONAL | seller-set | The seats that may commit. An entry with an absent or empty `ids[]` names no seat and admits no one, unlike an absent OpenRTB 2.6 `Deal.wseat`, which sets no restriction | | `terms_acceptance_required` | Boolean | OPTIONAL | seller-set | Whether legal terms must be accepted before assent | | `terms_ref` | Object | REQUIRED when `terms_acceptance_required` is `true` | seller-set | `{ url, sha256, version }`: the legal terms or IO template the buyer accepts at assent. Hash-pinned, so assent (§ 4.1) binds to an exact document | @@ -931,6 +982,54 @@ self-contained. `exclusions` (§ 5.5) reveal the buyer's strategy. A seller MUST NOT disclose a buyer's `exclusions` to any party other than that buyer. +**Who may read.** Each `visibility` value names who may read the line item: + +- `public`: any requester, with no authentication. +- `registry_gated`: an authenticated requester (§ 1.2) acting for a party the + IAB Tech Lab Agent Registry attests. +- `named`: an authenticated requester acting for a party that `eligible_buyers[]` + names, or that holds a seat `eligible_seats[]` names. + +How a seller learns which party a requester acts for, or that it holds a seat, is +authorization (§ 1.2), and the object doesn't carry it. + +An authenticated requester acting for the party a proposal's `buyer` names may read all +of it, whatever `visibility` says. Any other requester may read a proposal's own fields +only when it may read one of its line items. A response carries only the line items the +requester may read, and a request for any other line item, or for a proposal the +requester may not read, returns `not_found` (§ 2.4). A full fetch leaves out only line +items (§ 2.1), so a value it returns can name a line item the requester may not read, +such as a line item's `requires` (§ 5.6.3) or a `negotiation_history[]` path. + +**Who may commit.** A buyer is eligible when each of these that is present holds: + +- `eligible_buyers[]`, `eligible_seats[]`: `buyer.domain` MUST be in + `eligible_buyers[]`, or `buyer_requirements.seats` MUST name at least one seat, each + in an `eligible_seats[]` entry for its `dsp`. +- `buyer_requirements.allowed_dsps[]` (§ 5.9): when `buyer_requirements.seats` is + present, its `dsp` MUST be in the list. +- `required_credentials[]`: the buyer meets it. + +A buyer that fails these rules, or an authority check the seller applies (whether the +sender may make this request for `buyer`, or `buyer` for an advertiser it names, § 1.2), +gets `not_eligible` (§ 2.4), or `not_found` when the requester may not read what the request +names. That is a rejection, not a decline (§ 2.4). A mandate is not a field of this +object. + +**`RequiredCredential`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `credential_type` | VocabularyTerm | REQUIRED | What the buyer holds. Core term: `iab_registry`, an entry in the IAB Tech Lab Agent Registry | +| `issuer` | Domain | REQUIRED | Who issues it | + +A consumer that does not know a `credential_type` MUST NOT treat the proposal or +line item as invalid for that reason. It MUST treat that entry as unsatisfiable. + +Agent identity and request authentication are defined by the AAMP agent identity +and signing profile (provisional name). They are not expressed through +`required_credentials[]`. + ### 5.9 Execution | Field | Type | Required | Mutability | Description | @@ -939,22 +1038,54 @@ buyer's `exclusions` to any party other than that buyer. | `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` | +| `buyer_requirements` | BuyerRequirements | OPTIONAL | mixed | See below | | `serving_platform` | String | OPTIONAL | seller-set | Where the line item serves | **`TransactionMechanism`** is `direct_io` \| `programmatic_guaranteed` \| `preferred_deal` \| `private_auction` \| `open_auction`. +**`BuyerRequirements`** keys: + +| Key | Type | Required | Meaning | Mutability | +| :--- | :--- | :--- | :--- | :--- | +| `seats_expected` | Boolean | OPTIONAL | The seller asks the buyer to supply `seats` | seller-set | +| `allowed_dsps[]` | Array[RegistrableDomain] | OPTIONAL | The DSPs that may receive the deal (§ 5.8), each by its primary domain, as `SeatRef.dsp` (§ 2.2) | seller-set | +| `seats` | SeatRef | OPTIONAL | The buyer's seats on the deal, as the Deals API 1.0 `wseat` | supplied | +| `seat_owner` | Party | OPTIONAL | The legal entity that owns the seats. Absent: the `buyer` | supplied | + ### 5.10 External references and settlement | 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. Documented `role` values: `io`, an IO id generated by an order management system and carried back on the line item; `agreement`, a buyer-held agreement id | -| `agreement_ref` | Object | OPTIONAL | seller-set | Reference to a master or annual agreement the line item is priced under: `{ agreement_id, parties[], type: upfront \| annual \| master_services \| other, valid_from, valid_until }`, where `parties[]` names the seller by its `seller_id` and the buyer by the id it holds in `eligible_bidders[]` (§ 5.8). An informative pointer; the agreement itself is outside this specification. A buyer-held agreement id travels as an `external_references[]` entry with `role: agreement` | +| `external_references[]` | Array[ExternalReference] | OPTIONAL | recorded | `{ system, namespace, id, role }`. Replaces platform-specific key names. The party that learns an id writes the entry. `role` is a `VocabularyTerm`. Core terms: `io`, an IO id generated by an order management system and carried back on the line item; `agreement`, a buyer-held agreement id | +| `agreement` | Agreement | OPTIONAL | seller-set | The master or annual agreement the line item is priced under. An informative pointer; the agreement itself is outside this specification. A buyer-held agreement id travels as an `external_references[]` entry with `role: agreement` | | `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 | +| `billing` | Billing | OPTIONAL | seller-set | 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 | +**`Agreement`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `agreement_id` | String | REQUIRED | The agreement's id | +| `parties[]` | Array[Party] | OPTIONAL | The agreement's parties | +| `agreement_type` | VocabularyTerm | OPTIONAL | Core terms: `upfront` \| `annual` \| `master_services`. A receiver that doesn't know the term still reads the `agreement` | +| `term` | Interval | OPTIONAL | When the agreement runs | +| `document` | DocumentRef | OPTIONAL | The agreement, where the parties pin it | + +**`Billing`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `delivering_party` | Party | OPTIONAL | Who delivers. Absent: the `seller` | +| `invoicing_party` | Party | OPTIONAL | Who invoices. Absent: the `seller` | +| `payment_terms` | String | OPTIONAL | The payment terms, such as `net 30` | +| `invoice_period` | VocabularyTerm | OPTIONAL | The invoice period. Core terms: `month` (default) \| `cycle` | +| `cycle_length` | Duration | REQUIRED when `invoice_period` is `cycle` | How long each `cycle` period runs, such as `P28D` (4 weeks) | +| `time_zone` | IanaZone | OPTIONAL | The clock for a `month` invoice period. Absent: `UTC` | + +A buyer's own code for a supplier goes in `external_references[]`. + This specification does not define IO generation. IO creation is a connector concern. OpenProposal supplies the agreed record, `terms_ref` and `correlation_id` a connector needs. @@ -1023,9 +1154,9 @@ display line item is `registry_gated`, so it is not listed. See **Relationships, agreements and IOs (non-normative).** Buyer–seller relationships are expressed as issued proposals (`visibility: named`, -`eligible_bidders[]` set to that buyer, with the negotiated `pricing[]`) plus +`eligible_buyers[]` set to that buyer, with the negotiated `pricing[]`) plus the buyer agent's business rules. "Preferred partner" weighting is a buyer-side -ranking rule, not part of this specification. `agreement_ref` and `terms_ref` +ranking rule, not part of this specification. `agreement` and `terms_ref` (§ 5.10, § 5.8) point at the agreement and terms a line item is executed under; IO generation happens through connectors. @@ -1059,6 +1190,9 @@ see [docs/open-questions.md](../docs/open-questions.md). names the release a document follows, and § 2.5 makes a key the receiver's release does not list invalid. Nothing says whether a receiver rejects a release it does not know, or keeps and ignores keys it does not list. +11. **Must a manifest response carry both `ETag` and `Last-Modified`, or is either + enough?** § 2.3 requires at least one, so a buyer's client handles whichever the + seller sends. --- @@ -1073,8 +1207,8 @@ body is normative.** | `proposal_id` | 4.1 Identity and lifecycle | String | REQUIRED | immutable | | `version` | 4.1 Identity and lifecycle | Integer | REQUIRED | derived | | `spec_version` | 4.1 Identity and lifecycle | String | REQUIRED | seller-set | -| `seller_id` | 4.1 Identity and lifecycle | String | REQUIRED | immutable | -| `seller_name` | 4.1 Identity and lifecycle | String | OPTIONAL | immutable | +| `seller` | 4.1 Identity and lifecycle | Party | REQUIRED | immutable | +| `buyer` | 4.1 Identity and lifecycle | Party | REQUIRED when `assent` is present | 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 | @@ -1135,9 +1269,10 @@ body is normative.** | `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 | +| `visibility` | 5.8 Transaction eligibility | Enum | REQUIRED | seller-set | +| `required_credentials[]` | 5.8 Transaction eligibility | Array[RequiredCredential] | OPTIONAL | seller-set | +| `eligible_buyers[]` | 5.8 Transaction eligibility | Array[RegistrableDomain] | OPTIONAL | seller-set | +| `eligible_seats[]` | 5.8 Transaction eligibility | Array[SeatRef] | OPTIONAL | seller-set | | `terms_acceptance_required` | 5.8 Transaction eligibility | Boolean | OPTIONAL | seller-set | | `terms_ref` | 5.8 Transaction eligibility | Object | REQUIRED when `terms_acceptance_required` is `true` | seller-set | | `buying_route` | 5.9 Execution | Enum | OPTIONAL | seller-set | @@ -1147,9 +1282,9 @@ body is normative.** | `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 | -| `agreement_ref` | 5.10 External references and settlement | Object | OPTIONAL | seller-set | +| `agreement` | 5.10 External references and settlement | Agreement | OPTIONAL | seller-set | | `correlation_id` | 5.10 External references and settlement | String | OPTIONAL | derived | -| `billing` | 5.10 External references and settlement | Object | OPTIONAL | seller-set | +| `billing` | 5.10 External references and settlement | Billing | OPTIONAL | seller-set | | `supported_order_systems[]` | 5.10 External references and settlement | Array[String] | OPTIONAL | seller-set | --- @@ -1158,8 +1293,8 @@ body is normative.** | Marker | Count | Meaning | Fields | | :--- | :--- | :--- | :--- | -| 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 | 38 | The seller sets it and MAY revise it while status is `draft` or `published`. The buyer cannot change it directly. | `spec_version`, `offer_window`, `line_items[]`, `linked_execution`, `ext` (proposal), `description`, `specifications`, `best_for[]`, `not_suitable_for[]`, `seasonality_notes`, `ext` (line item), `content_detail`, `specs`, `third_party_tags`, `creative_delivery`, `approval`, `exclusions_accepted`, `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`, `terms_ref`, `buying_route`, `serving_mode`, `serving_platform`, `agreement_ref`, `billing`, `supported_order_systems[]` | +| immutable | 7 | Set once at creation. Neither party may change it. | `proposal_id`, `seller`, `buyer`, `type`, `brief_id`, `line_item_id`, `channel` | +| seller-set | 39 | The seller sets it and MAY revise it while status is `draft` or `published`. The buyer cannot change it directly. | `spec_version`, `offer_window`, `line_items[]`, `linked_execution`, `ext` (proposal), `description`, `specifications`, `best_for[]`, `not_suitable_for[]`, `seasonality_notes`, `ext` (line item), `content_detail`, `specs`, `third_party_tags`, `creative_delivery`, `approval`, `exclusions_accepted`, `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_buyers[]`, `eligible_seats[]`, `terms_acceptance_required`, `terms_ref`, `buying_route`, `serving_mode`, `serving_platform`, `agreement`, `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` | | supplied | 1 | The buyer provides data or an artifact. The seller does not pre-populate it. | `exclusions` | @@ -1172,8 +1307,8 @@ body is normative.** ### What the distribution says -Of 81 fields, **20 are touched by the buyer** — 12 `selectable`, 5 `settable`, 1 -`supplied` and 2 `requestable`. The remaining 61 are `seller-set`, `immutable`, +Of 82 fields, **20 are touched by the buyer** — 12 `selectable`, 5 `settable`, 1 +`supplied` and 2 `requestable`. The remaining 62 are `seller-set`, `immutable`, `derived`, `lifecycle`, `recorded`, `asserted` or `mixed`. Only the 2 `requestable` fields require a round trip to resolve (§ 5.6.1). From ba259e89c054e5daa02f8795df819ddf49a1c1d1 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Wed, 4 Nov 2026 00:41:37 +0100 Subject: [PATCH 32/47] Define who moves each status and when an offer goes stale Separate shared and issued proposals, and add issued_from. Define the binding record, record_digest, and assent[] entries, freeze a version's offered terms, and state one staleness rule for asks and holds. Give each act one history action, name the status types, make the line-item transitions exhaustive, define a firm entry per pricing entry, and list the allowed decline grounds once. State agreed once. Remove buyer state from the shared examples, add an issued, partly agreed example, and recompute its record_digest in the checker. --- examples/line-item-audio.yaml | 8 - examples/line-item-ctv.yaml | 11 +- examples/line-item-display.yaml | 21 +- examples/manifest.json | 3 +- examples/proposal-issued.yaml | 97 ++++++ examples/proposal.yaml | 11 +- scripts/check_appendix.py | 5 +- scripts/check_examples.py | 103 ++++++- spec/CHANGELOG.md | 28 ++ spec/openproposal-3.0.md | 516 ++++++++++++++++++++++++++++---- 10 files changed, 698 insertions(+), 105 deletions(-) create mode 100644 examples/proposal-issued.yaml diff --git a/examples/line-item-audio.yaml b/examples/line-item-audio.yaml index 8e061cf..e172491 100644 --- a/examples/line-item-audio.yaml +++ b/examples/line-item-audio.yaml @@ -21,14 +21,6 @@ pricing: price_valid_until: "2026-12-15T23:59:59Z", currency: USD } commitment_bounds: { min_budget: 5000.00 } -commitment: - proposal_id: "examplepub-us-q4-2026-omni" - version: 1 - basis: spend - amount: 60000.00 - currency: USD - 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 diff --git a/examples/line-item-ctv.yaml b/examples/line-item-ctv.yaml index 51938ac..3b0403b 100644 --- a/examples/line-item-ctv.yaml +++ b/examples/line-item-ctv.yaml @@ -16,18 +16,9 @@ pricing: price_valid_until: "2026-12-01T23:59:59Z", currency: USD } commitment_bounds: { min_budget: 25000.00 } -commitment: - proposal_id: "examplepub-us-q4-2026-omni" - version: 1 - basis: spend - amount: 160000.00 - currency: USD - 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", - hold_scope: line_item, state: held, - hold_expires_at: "2026-10-01T00:00:00Z" } + hold_scope: line_item } committed_metrics: [{ metric: completion_rate, value: 0.95, basis: seller_reported }] visibility: public diff --git a/examples/line-item-display.yaml b/examples/line-item-display.yaml index f549a1e..e86e273 100644 --- a/examples/line-item-display.yaml +++ b/examples/line-item-display.yaml @@ -1,6 +1,5 @@ # OpenProposal 3.0 — Example: Display line item, fully worked (spec § 6.2) -# Shows selectable audiences with per-option pricing, a buyer commitment, and -# settable bounds. +# Shows selectable audiences with per-option pricing and settable bounds. line_item_id: "business-daily-display" status: published channel: display @@ -37,7 +36,6 @@ buyer_supplied: segments_allowed: true suppression_allowed: true match_rate_expected: 0.65 - suppression_list: { source: "advertiser_crm", size: 340000, match_method: "ramp_id" } formats: available: ["display/300x250", "display/728x90", "display/970x250"] @@ -54,14 +52,10 @@ exclusions_accepted: # what this seller can enforce (§ 5.5) types: [domain, iab_content_category, keyword] max_entries: { domain: 5000, keyword: 500 } enforcement: { domain: pre_serve, iab_content_category: pre_serve, keyword: best_effort } -exclusions: # supplied by the buyer - domain: ["example-gambling.example"] - keyword: ["crypto"] pricing: - cost_method: CPM gross_rate: 24.00 - agreed_rate: 24.00 # derived: no priced options included seller_rate: 20.40 floor: 21.00 price_valid_until: "2026-11-15T23:59:59Z" @@ -69,19 +63,8 @@ pricing: commitment_bounds: { min_budget: 10000.00, max_budget: 500000.00 } -commitment: - proposal_id: "examplepub-us-q4-2026-omni" - version: 1 - basis: spend - amount: 80000.00 - currency: USD - flight: { start: "2026-10-06T00:00:00Z", end: "2026-12-21T23:59:59Z" } - advertisers: [{ domain: "brand-a.example" }] - derived_units: 3333333 # derived: 80000.00 / 24.00 × 1000 - idempotency_key: "agency-a-camp1-business-daily-display-8f42c1a9" - hold_status: { hold_available: true, hold_duration: "P7D", - hold_scope: line_item, state: none } + hold_scope: line_item } committed_metrics: - { metric: viewable_rate, value: 0.70, basis: mrc } diff --git a/examples/manifest.json b/examples/manifest.json index 981e456..7144fcf 100644 --- a/examples/manifest.json +++ b/examples/manifest.json @@ -4,7 +4,7 @@ "proposals": [ { "proposal_id": "examplepub-us-q4-2026-omni", - "version": 1, + "version": 2, "seller": { "domain": "examplepub.example", "legal_name": "Example Publisher US" @@ -14,7 +14,6 @@ "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": [ { diff --git a/examples/proposal-issued.yaml b/examples/proposal-issued.yaml new file mode 100644 index 0000000..21ee7eb --- /dev/null +++ b/examples/proposal-issued.yaml @@ -0,0 +1,97 @@ +# OpenProposal 3.0 — Example: Issued proposal, partly agreed (spec § 6.12) +# Illustrative only. Seller names and rates are fictional. The seller issues this +# proposal to Agency A from version 2 of the shared proposal in examples/proposal.yaml. +# One line item is agreed at version 1. The seller then revises the other, so the +# proposal is at version 2 and the entries still name version 1. A party verifies them +# over version 1, restricted to their line_item_ids (§ 4.1.2). +proposal_id: "examplepub-agencya-q4-2026-omni" +version: 2 +spec_version: "3.0" +seller: { domain: "examplepub.example", legal_name: "Example Publisher US", + registry_id: "reg:examplepub-digital" } +buyer: { domain: "agency-a.example", legal_name: "Agency A LLC" } +type: standard +issued_from: { proposal_id: "examplepub-us-q4-2026-omni", version: 2 } +status: published # derived: one line item remains offered (§ 4.3.3) +offer_window: { start: "2026-10-01T00:00:00Z", end: "2026-12-31T23:59:59Z" } + +description: > + Display and CTV line items for Agency A, issued from the Q4 omnichannel proposal. + +assent: + - { role: commitment, version: 1, + record_digest: "sha256:3a91ba82c784a4c2f6800f0be97522cfaf755281729fbad43daab8d8bfe1dd2a", + line_item_ids: ["business-daily-display"], + signed_at: "2026-10-05T10:14:03Z" } + - { role: acceptance, version: 1, + record_digest: "sha256:3a91ba82c784a4c2f6800f0be97522cfaf755281729fbad43daab8d8bfe1dd2a", + line_item_ids: ["business-daily-display"], + signed_at: "2026-10-05T10:31:47Z" } + +negotiation_history: + - { version: 1, actor: seller, action: published, paths: [], + at: "2026-10-04T09:00:00Z" } + - { version: 2, actor: seller, action: published, + paths: [{ line_item_id: "weather-app-ctv", pointer: "/materials_due" }], + at: "2026-10-06T16:20:00Z" } + +line_items: + - line_item_id: "business-daily-display" + status: agreed + channel: display + properties: + available: [{ id: "business-daily", domains: ["business-daily.example"] }] + included: ["business-daily"] + buyer_supplied: + suppression_allowed: true + suppression_list: { source: "advertiser_crm", size: 340000, match_method: "ramp_id" } + formats: + available: ["display/300x250"] + included: ["display/300x250"] + exclusions_accepted: + types: [domain, keyword] + max_entries: { domain: 5000, keyword: 500 } + enforcement: { domain: pre_serve, keyword: best_effort } + exclusions: # supplied by the buyer + domain: ["example-gambling.example"] + keyword: ["crypto"] + pricing: + - { cost_method: CPM, gross_rate: 24.00, agreed_rate: 24.00, + price_valid_until: "2026-11-15T23:59:59Z", currency: USD } + commitment_bounds: { min_budget: 10000.00, max_budget: 500000.00 } + commitment: + proposal_id: "examplepub-agencya-q4-2026-omni" + version: 1 + basis: spend + amount: 80000.00 + currency: USD + flight: { start: "2026-10-12T00:00:00Z", end: "2026-12-21T23:59:59Z" } + advertisers: [{ domain: "brand-a.example" }] + derived_units: 3333333 # derived: 80000.00 / 24.00 × 1000 + idempotency_key: "agency-a-camp1-business-daily-display-8f42c1a9" + hold_status: { hold_available: true, hold_duration: "P7D", + hold_scope: line_item, state: none } + visibility: named + eligible_buyers: ["agency-a.example"] + transaction_mechanism: { available: ["programmatic_guaranteed"], + included: ["programmatic_guaranteed"] } + + - line_item_id: "weather-app-ctv" + status: published + channel: ctv + properties: { available: [{ id: "weather-app" }], included: ["weather-app"] } + formats: + available: ["ctv/instream-15s"] + included: ["ctv/instream-15s"] + materials_due: { at: "2026-10-20T23:59:59Z" } # version 1 had "2026-10-13T23:59:59Z" + pricing: + - { cost_method: CPM, gross_rate: 45.00, + price_valid_until: "2026-12-01T23:59:59Z", currency: USD } + commitment_bounds: { min_budget: 25000.00 } + hold_status: { hold_available: true, hold_duration: "P14D", # re-offered at version 2 + hold_scope: line_item, state: held, + hold_expires_at: "2026-10-20T00:00:00Z" } + visibility: named + eligible_buyers: ["agency-a.example"] + transaction_mechanism: { available: ["programmatic_guaranteed"], + included: ["programmatic_guaranteed"] } diff --git a/examples/proposal.yaml b/examples/proposal.yaml index 325321e..f480e2c 100644 --- a/examples/proposal.yaml +++ b/examples/proposal.yaml @@ -1,17 +1,24 @@ # OpenProposal 3.0 — Example: Proposal (spec § 6.1) # Illustrative only. Seller names and rates are fictional. proposal_id: "examplepub-us-q4-2026-omni" -version: 1 +version: 2 spec_version: "3.0" seller: { domain: "examplepub.example", legal_name: "Example Publisher US", registry_id: "reg:examplepub-digital" } # OPTIONAL: an attestation (§ 2.2) type: standard -linked_execution: none # line items may be bought separately (§ 4.1) proposal_template_id: "examplepub-q4-omni-template" # seller-side; buyers never resolve it status: published 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). +# Version 1 stays retrievable, unchanged (§ 4.1.3). +negotiation_history: + - { version: 1, actor: seller, action: published, paths: [], + at: "2026-09-22T09:00:00Z" } + - { version: 2, actor: seller, action: published, + paths: [{ line_item_id: "business-daily-display", pointer: "/creative_delivery" }, + { pointer: "/seasonality_notes" }], + at: "2026-10-03T14:12:40Z" } description: > Cross-channel Example Publisher US package spanning display, in-stream video diff --git a/scripts/check_appendix.py b/scripts/check_appendix.py index 520d447..15b6b95 100755 --- a/scripts/check_appendix.py +++ b/scripts/check_appendix.py @@ -49,6 +49,8 @@ # Other tables state rules as prose does (section 2.6). ("OpenProposal concept", "Downstream standard"), ("Layer", "Question", "Answered by", "Owner", "In the proposal object"), + ("Interaction", "Actor", "Effect; history `action`"), + ("Instant", "Ends", "Effect when passed"), # Generated. ("Field", "Section", "Type", "Required", "Marker"), ("Marker", "Count", "Meaning", "Fields"), @@ -62,7 +64,7 @@ # 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", + "AssentEntry": "the assent entry", "PropertyOption": "the property option", "AudienceOption": "the audience option", "BuyerSupplied": "the buyer-supplied audience data", @@ -77,7 +79,6 @@ # 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", diff --git a/scripts/check_examples.py b/scripts/check_examples.py index 3e84dc7..8817403 100755 --- a/scripts/check_examples.py +++ b/scripts/check_examples.py @@ -14,6 +14,9 @@ - The manifest is a strict path subset of the full objects (section 2.1), and lists each public, published line item (section 2.3). - seller and buyer on a proposal hold legal_name (section 4.1). + - Each section 4.1.1 removal path resolves to a table row. + - Each assent[] entry's record_digest recomputes under section 4.1.1, over the + line items its line_item_ids[] names. KNOWN holds findings that a later change removes. It only shrinks. Run with --regen-manifest to rewrite examples/manifest.json from the full examples. @@ -21,6 +24,7 @@ Run: python3 scripts/check_examples.py """ import argparse +import hashlib import json import re import sys @@ -52,7 +56,13 @@ # Each `REQUIRED when ` wording, as (object, line item, proposal) -> bool. CONDITIONS = { "`type` is `custom`": lambda o, i, p: o.get("type") == "custom", - "`assent` is present": lambda o, i, p: "assent" in o, + "`type` is `custom` or `assent[]` is present": + lambda o, i, p: o.get("type") == "custom" or "assent" in o, + "the current `version` is frozen (§ 4.1.3)": lambda o, i, p: o.get("status") != "draft", + "`requested_value` is present": lambda o, i, p: "requested_value" in o, + "the entry's `action` is `requested`": + lambda o, i, p: any(e.get("action") == "requested" and any(x is o for x in e.get("paths", [])) + for e in (p or {}).get("negotiation_history", [])), "`invoice_period` is `cycle`": lambda o, i, p: o.get("invoice_period") == "cycle", "`terms_acceptance_required` is `true`": lambda o, i, p: i.get("terms_acceptance_required") is True, @@ -76,6 +86,7 @@ TERM_DOMAIN = r"[a-z0-9-]+(?:\.[a-z0-9-]+)+:" TERM = re.compile(rf"(?:{TERM_DOMAIN})?{TERM_PART}\Z") SIDE = ("buyer", "seller") +PROPOSAL_STATUS = ("draft", "published", "under_review", "agreed", "withdrawn", "expired") CALENDAR_UNIT = ("hour", "day", "week", "month") SCALARS = { "Instant": (lambda v: isinstance(v, str) and INSTANT.match(v)), @@ -100,6 +111,9 @@ "ZoneName": (lambda v: isinstance(v, str)), "IanaZone": (lambda v: isinstance(v, str)), "Side": (lambda v: v in SIDE), + "ProposalStatus": (lambda v: v in PROPOSAL_STATUS), + "LineItemStatus": (lambda v: v in PROPOSAL_STATUS or v == "sold_out"), + "ApprovalStatus": (lambda v: v in ("PENDING", "APPROVED", "REJECTED")), "EntryRef": (lambda v: isinstance(v, str) and re.fullmatch(r"[^/@]+/[^/@]+/[^/@]+@[^/@]+", v)), "FieldPointer": (lambda v: isinstance(v, str) and (v == "" or v.startswith("/"))), "Ext": (lambda v: isinstance(v, dict) and all( @@ -175,6 +189,7 @@ def __init__(self): self.tables = {} self.manifest_keys = {} self.summary_paths, self.proposal_paths = [], [] + self.removal = [] self._read_fields() self._read_key_tables() self._read_lists() @@ -229,6 +244,8 @@ def _read_lists(self): self.summary_paths = m.group(1).split() m = re.search(r"`ProposalSummary` lists these paths[^\n]*\n\n```text\n(.*?)```", text, re.S) self.proposal_paths = m.group(1).split() + m = re.search(r"#### 4\.1\.1.*?```\n(.*?)```", text, re.S) + self.removal = [l for l in m.group(1).splitlines() if l.startswith("$")] # Type walking -------------------------------------------------------------- @@ -439,6 +456,78 @@ def check_pricing(self, file, path, entries, fields): if key in entry: self.check_value(file, f"{sub}.{key}", entry[key], row) + def check_removal_paths(self): + for path in self.spec.removal: + head_path = path.split(",")[0] + parts = [p for p in head_path.lstrip("$.").split(".") if p != "*"] + parts = [re.sub(r"\[\*\]$", "", p) for p in parts] + fields = self.spec.proposal + if parts[0] == "line_items": + fields, parts = self.spec.line_item, parts[1:] + ok = parts[0] in {bare(k) for k in fields} + if ok and len(parts) > 1 and parts[0] == "pricing": + ok = f"pricing[].{parts[1]}" in fields + if not ok: + self.err("spec", "removal path", path, f"{path} resolves to no row") + + +# Digest -------------------------------------------------------------------- + + +def jcs_number(n): + if isinstance(n, bool): + return "true" if n else "false" + if isinstance(n, int): + return str(n) + if n == int(n) and abs(n) < 1e21: + return str(int(n)) + return repr(n) + + +def jcs(value): + """RFC 8785 serialization, for the values examples use.""" + if value is None: + return "null" + if isinstance(value, (bool, int, float)): + return jcs_number(value) + if isinstance(value, str): + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + if isinstance(value, list): + return "[" + ",".join(jcs(v) for v in value) + "]" + keys = sorted(value, key=lambda k: k.encode("utf-16-be")) + return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + jcs(value[k]) for k in keys) + "}" + + +def remove_path(node, parts, keep=()): + if not parts: + return + head, rest = parts[0], parts[1:] + if head.endswith("[*]"): + for item in (node.get(head[:-3]) or []): + remove_path(item, rest, keep) + return + if head == "*": + if isinstance(node, dict): + for key in list(node): + if key not in keep: + del node[key] + return + if not rest: + node.pop(head, None) + elif isinstance(node.get(head), dict): + remove_path(node[head], rest, keep) + + +def record_digest(doc, removal, line_item_ids): + """Section 4.1.1: the digest of the binding record for the named line items.""" + copy = json.loads(json.dumps(doc)) + copy["line_items"] = [i for i in copy.get("line_items", []) if i["line_item_id"] in line_item_ids] + for path in removal: + head, _, tail = path.partition(", except ") + keep = re.split(r",\s*|\s+and\s+", tail) if tail else () + remove_path(copy, head[2:].split("."), keep) + return "sha256:" + hashlib.sha256(jcs(copy).encode("utf-8")).hexdigest() + # Summary ------------------------------------------------------------------- @@ -545,6 +634,16 @@ def main(): check_manifest(checker, spec) except (ValueError, KeyError) as e: checker.errors.append(f"manifest.json: {e}") + checker.check_removal_paths() + + digests = {} + for path in sorted(EXAMPLES.glob("*.yaml")): + doc = load_yaml(path) + for entry in doc.get("assent", []): + digest = record_digest(doc, spec.removal, entry["line_item_ids"]) + digests[path.name] = digest + if entry["record_digest"] != digest: + checker.errors.append(f"{path.name}: record_digest recomputes to {digest}") stale = KNOWN - checker.used_known for item in sorted(stale): @@ -556,6 +655,8 @@ def main(): print(f" - {e}") return 1 print("Examples match the tables.") + for name, digest in digests.items(): + print(f" record_digest of {name}: {digest}") return 0 diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index e1b4d43..19dcaa5 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,6 +5,34 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] +- § 1.2, § 2.2, § 2.3, § 2.4, § 3, § 4.1, § 4.1.1, § 4.1.2, § 4.1.3, § 4.3, § 5.1, § 5.4, + § 5.6, § 5.6.1, § 5.6.3, § 5.8, § 5.9, § 6, § 7, appendices, examples, checkers — who + moves each status, and when an offer goes stale. The `seller-set` gate reads the `status` + of the object that carries the field. § 4.1.1 and § 4.1.2 define the binding record, + `record_digest`, and `assent[]` entries, which replace the `assent` object. § 4.1.3 freezes + a version's offered terms, and § 4.3 states who moves each status. A resolved `commitment` + is an offer until the seller accepts it. A proposal with no `buyer` is shared, and one with + a `buyer` is issued; `issued_from`, a `VersionRef`, is added, and a `custom` proposal is + issued from the start. An ask is stale only when the binding record of the line items it + names differs between its `version` and the current one. History actions become + `published`, `requested`, `granted`, `countered`, `declined`, and `withdrawn`, and a + `commit` and its acceptance are in `assent[]` only. `accept` answers only a `commit`; a + `request` is answered by `grant`, `counter`, or `decline`, and is identified by the value + it asks. `HistoryEntry`, `HistoryPath`, `RequestedValue`, `ProposalStatus`, + `LineItemStatus`, and `ApprovalStatus` are named types. The status table is exhaustive, a + firm entry is defined per `pricing[]` entry, and the allowed grounds for declining one are + listed once. A `draft` line item leaves the proposal `published`. `linked_execution` takes + only `all`, which covers every `line_item_id` the version carries, and `offer_window` does + not hold before `start`. `agreed` is stated once. Breaking + ([#60](https://github.com/IABTechLab/OpenProposal/issues/60), + [#64](https://github.com/IABTechLab/OpenProposal/issues/64), + [#65](https://github.com/IABTechLab/OpenProposal/issues/65), + [#67](https://github.com/IABTechLab/OpenProposal/issues/67), + [#63](https://github.com/IABTechLab/OpenProposal/issues/63), + [#68](https://github.com/IABTechLab/OpenProposal/issues/68), + [#70](https://github.com/IABTechLab/OpenProposal/issues/70), + [#26](https://github.com/IABTechLab/OpenProposal/issues/26), + [#24](https://github.com/IABTechLab/OpenProposal/issues/24)). - § 1.2, § 2.1 to § 2.5, § 4.1, § 5.6.1, § 5.8, § 5.9, § 5.10, § 7, § 8, appendices, examples, docs, checker — who the parties are, and what each identity layer proves. `seller_id` and `seller_name` become `seller`, a `Party` whose `legal_name` is diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index c7379ad..0f063d6 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -59,7 +59,7 @@ seller alike. An answer to one is not an answer to another. | :--- | :--- | :--- | :--- | :--- | | Request authentication | Which origin sent this request? | An [HTTP Message Signature](https://www.rfc-editor.org/rfc/rfc9421), with the key in that origin's [Web Bot Auth](https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/) directory | The AAMP agent identity and signing profile (provisional name) | Nothing. The object never carries or re-proves it | | Authorization | What may this party, or its agent, do? | The seller's eligibility rules; a mandate the principal publishes or signs | This specification for the rules (§ 5.8); the AAMP authority document (provisional name) for mandates | The rules only, never the proof | -| Legal assent | Which legal entity agreed to which terms? | The `assent` records for the binding version, with both parties named in the proposal | This specification for the parties and the record (§ 4.1); the profile for any signature over it | Parties, `assent` | +| Legal assent | Which legal entity agreed to which terms? | The `assent[]` entries for the binding version, with both parties named in the proposal | This specification for the parties and the record (§ 4.1); the profile for any signature over it | Parties, `assent[]` | A request signature proves an `https` origin. It does not prove which party that origin speaks for: a hosted agent's origin is the host's, and a mandate the party @@ -166,6 +166,9 @@ shared type or changes its form. | `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 | +| `ProposalStatus` | `draft` \| `published` \| `under_review` \| `agreed` \| `withdrawn` \| `expired` | A proposal's `status`, computed by § 4.3.3 | +| `LineItemStatus` | A `ProposalStatus`, or `sold_out` | A line item's `status`, moved as § 4.3.2 states | +| `ApprovalStatus` | `PENDING` \| `APPROVED` \| `REJECTED` | An approval outcome, in the spelling of OpenDirect 2.1's ChangeRequest `status` | | `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 | @@ -278,7 +281,7 @@ A seller MAY publish `https:///.well-known/openproposal.json`, a - At least one of `ETag` and `Last-Modified` is REQUIRED, so buyers can poll cheaply. § 8 asks whether both are. -- Only standard proposals appear, and within them only line items whose +- Only shared proposals (§ 4.3) appear, and within them only line items whose `visibility` (§ 5.8) is `public` and whose `status` is `published`. `registry_gated` and `named` line items do not. - A buyer discards an entry whose `seller.domain` differs from the domain in the @@ -293,7 +296,8 @@ 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. + `full_url`. Asks, holds, and commits run on a proposal issued to the buyer + (§ 4.3). ### 2.4 Errors @@ -323,10 +327,10 @@ Core codes: `validation` \| `not_found` \| `not_offered` \| `unresolved_referenc | :--- | :--- | | `validation` | A value breaks a rule of this specification and no other code applies | | `not_found` | A named proposal or line item does not exist, or the requester may not read it (§ 5.8) | -| `not_offered` | A buyer value is outside what the seller declared (§ 3.1, § 3.2, § 5.5) | +| `not_offered` | A buyer value is outside what the seller declared (§ 3.1, § 3.2, § 4.3.2, § 5.5) | | `unresolved_reference` | A `DocumentRef` (§ 2.2) does not resolve, or its content does not match its `digest` | -| `not_committable` | A line item's `status`, or a `requires` that is unmet (§ 5.6.3), rules out commitment | -| `stale_version` | The commitment was composed on terms that are not the current terms (§ 5.6.1) | +| `not_committable` | A line item's `status` rules out an ask (§ 4.3.2), a `requires` that is unmet rules out a `commit` (§ 5.6.3), or a seller rejects a `commit` before `offer_window.start` (§ 5.6.1) | +| `stale_version` | An ask is stale (§ 4.1.3) | | `expired` | `offer_window.end` or `price_valid_until` has passed | | `idempotency_conflict` | An `idempotency_key` is reused with a different body | | `not_eligible` | The buyer or the sender fails an eligibility or authority check the seller applies (§ 5.8) | @@ -363,7 +367,7 @@ ext: it does not list is invalid. The open objects `DocumentRef` (§ 2.2) and `ErrorObject` (§ 2.4) are unaffected. - On the proposal and a line item, `ext` is part of the Full Representation and - of the record frozen at `agreed`, and a seller change to it is a + of the record frozen at `agreed` (§ 4.1.1), and a seller change to it is a revision. - Apart from an unknown term (§ 2.2), a receiver MAY skip a whole line item it cannot read, without assenting to it. It MUST NOT keep a line item after removing @@ -476,7 +480,7 @@ Every field carries exactly one marker. | Marker | Meaning | | :--- | :--- | | `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. | +| `seller-set` | The seller sets it and MAY revise it while the `status` of the object that carries it is `draft`, `published` or `under_review`. § 4.3.2 states what a revision does to a line item `under_review`. The buyer cannot change it directly. | | `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. | @@ -487,11 +491,21 @@ Every field carries exactly one marker. | `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[]`. | +**Whose `status`.** The proposal's (§ 4.1) for a field defined in § 4; the line +item's (§ 5.1) for a field defined in § 5, including `seller-set` sub-fields of +a `mixed` field (`pricing[]`, `buyer_requirements`) and a field treated as +`seller-set` under § 3.1 or § 3.2. + +§ 4.3, not the `seller-set` revision rule, governs the seller's answer to a `requestable` ask. + **Resolution without a round trip.** `selectable`, `settable` and `supplied` fields MUST resolve without a round trip — everything needed to validate and price them is declared in the proposal. Only `requestable` fields require the counterparty to respond. +Resolution is validation and pricing, not acceptance. A resolved `commitment` is +an offer to buy (§ 5.6.1). + A field is never silently reverted. An unset `selectable` or `settable` field takes its declared `included[]` or default, and that default MUST be explicit in the proposal. @@ -543,21 +557,52 @@ items. | Field | Type | Required | Mutability | Description | | :--- | :--- | :--- | :--- | :--- | | `proposal_id` | String | REQUIRED | immutable | Identifies the proposal. Unique within `seller.domain` | -| `version` | Integer | REQUIRED | derived | Monotonically increasing revision number. Each revision of the proposal increments it | +| `version` | Integer | REQUIRED | derived | Monotonically increasing revision number, starting at 1, held by the seller. Each version is a snapshot of the proposal's offered terms (§ 4.1.3). A version is frozen at the earliest of: the proposal's `status` leaving `draft`; a `negotiation_history[]` entry or a `commitment` naming it; a counterparty receiving it in any representation (§ 2.1). The offered terms of a frozen version MUST NOT change; a change to them creates version `n+1`. The values § 4.1.3 lists as not offered terms change without a new version | | `spec_version` | String | REQUIRED | seller-set | The version of this specification the document follows, such as `3.0` | -| `seller` | Party | REQUIRED | immutable | The selling entity. `seller.domain` is the seller's identifier for all purposes, including `assent`. `legal_name` is REQUIRED here | -| `buyer` | Party | REQUIRED when `assent` is present | immutable | The entity that assents, usually the agency. Absent until the seller issues the proposal to one buyer. Whom the spend is for is `commitment.advertisers[]` (§ 5.6.1), and whether the buyer may bind that advertiser is authorization (§ 1.2). `legal_name` is REQUIRED here | +| `seller` | Party | REQUIRED | immutable | The selling entity. `seller.domain` is the seller's identifier for all purposes, including `assent[]`. `legal_name` is REQUIRED here | +| `buyer` | Party | REQUIRED when `type` is `custom` or `assent[]` is present | immutable | The entity that assents, usually the agency. Its presence makes the proposal issued to that buyer (§ 4.3). Whom the spend is for is `commitment.advertisers[]` (§ 5.6.1), and whether the buyer may bind that advertiser is authorization (§ 1.2). `legal_name` is REQUIRED here | | `type` | Enum | REQUIRED | immutable | `standard` \| `custom` | -| `proposal_template_id` | String | OPTIONAL | immutable | Links an issued proposal to the seller's template. Opaque to buyers; used for the seller's reporting | -| `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** | +| `proposal_template_id` | String | OPTIONAL | immutable | Links a proposal to the seller's template. Opaque to buyers; used for the seller's reporting | +| `issued_from` | VersionRef | OPTIONAL | immutable | The shared proposal and version an issued proposal was made from (§ 4.3). A pointer: nothing is inherited through it | +| `status` | ProposalStatus | REQUIRED | derived | Computed from the line items (§ 4.3.3) | +| `record_digest` | Digest | OPTIONAL | derived | Of the binding record of this document over every line item it contains (§ 4.1.1). Either party MUST be able to recompute it from the document alone. Verifiable only against the Full Representation; a Summary Representation or manifest entry MAY carry it as a pointer | +| `assent[]` | Array[AssentEntry] | OPTIONAL | recorded | Assent entries. Each entry is written whole by the party its `role` implies, is immutable once written, and MUST NOT be altered or removed by the other party. Entry: `{ role: commitment \| acceptance, version, record_digest, line_item_ids[], signed_at, signer, signature }`. See § 4.1.2. The form of `signer`, the signature container, key discovery and agent authentication are defined by the AAMP agent identity and signing profile, not by this specification | +| `offer_window` | Interval | OPTIONAL | seller-set | The window the offer holds: it does not hold before `start` or after `end`. **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 | +| `negotiation_history[]` | Array[HistoryEntry] | REQUIRED when the current `version` is frozen (§ 4.1.3) | recorded | Ordered, append-only. Entries MUST NOT be edited or removed. A commit and its acceptance are in `assent[]`, not here | | `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) | +| `linked_execution` | Enum | OPTIONAL | seller-set | `all`. Every `line_item_id` the version carries is committed together, as though each line item's `requires.all_of[]` named the others (§ 5.6.3). Absent, the proposal is subsettable (§ 2) | | `ext` | Ext | OPTIONAL | seller-set | See Extensions (§ 2.5) | +**`HistoryEntry`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `version` | Integer | REQUIRED | The version the act created or refers to (§ 4.1.3) | +| `actor` | Side | REQUIRED | The side that acted | +| `action` | Enum | REQUIRED | `published` \| `requested` \| `granted` \| `countered` \| `declined` \| `withdrawn`. § 4.3.1 says which interaction writes each | +| `paths[]` | Array[HistoryPath] | REQUIRED | The fields the act changed or asked about. MAY be empty | +| `at` | Instant | REQUIRED | When the side acted | + +**`HistoryPath`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `line_item_id` | String | REQUIRED when `requested_value` is present | The line item. When absent, `pointer` is into the proposal | +| `pointer` | FieldPointer | REQUIRED | Into that line item or the proposal | +| `requested_value` | RequestedValue | REQUIRED when the entry's `action` is `requested` | On a `requested` entry, the value the buyer asked for. On a `declined` entry, or a buyer's `withdrawn` entry, the value of the `request` it ended. Absent on every other entry | + +**`RequestedValue`** holds exactly one key: the `requestable` field `pointer` names, in the +form its row states. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `materials_due` | MaterialsDue | OPTIONAL | The line item's `materials_due` | +| `committed_metrics[]` | Array[CommittedMetric] | OPTIONAL | The line item's `committed_metrics[]` | + +`proposal_template_id` MUST NOT be required for a buyer to transact. Buyers never +resolve it. + **Seller identity.** A proposal names exactly one `seller.domain`. A seller MUST NOT name a domain it does not control. For this specification one `seller.domain` is one seller: entities that transact under one domain are one seller here, and @@ -572,10 +617,221 @@ across domains, rebrands, or acquisitions is a fact the Agent Registry attests; object does not encode it. Whether to deal with a seller that has no Agent Registry entry is buyer policy. -- Each issued proposal has an independent `status`, `version` and - `negotiation_history[]`. -- `proposal_template_id` MUST NOT be required for a buyer to transact. Buyers - never resolve it. +#### 4.1.1 Binding record and record_digest + +The binding record of a document for a set of line items S is the JSON form of +that document in its Full Representation (§ 2.1), restricted to the line items in +S, with the following paths removed and no others. The paths are RFC 9535 JSONPath. +Arrays hash in document order, and no party reorders an array it did not write. The +hold's status is every `hold_status` key except `hold_available`, `hold_duration` +and `hold_scope`. + +``` +$.version +$.status +$.assent +$.record_digest +$.negotiation_history +$.line_items[*].status +$.line_items[*].availability +$.line_items[*].addressable_scale +$.line_items[*].assets +$.line_items[*].buyer_supplied.match_rate_expected +$.line_items[*].pricing[*].seller_rate +$.line_items[*].pricing[*].floor +$.line_items[*].hold_status.*, except hold_available, hold_duration and hold_scope +$.line_items[*].commitment.version +$.line_items[*].commitment.idempotency_key +$.line_items[*].commitment.derived_units +$.line_items[*].deal_id_issuance +$.line_items[*].external_references +$.line_items[*].correlation_id +``` + +Each removed path is one of three things: lifecycle state or sequence position +(`version`, `status`, `assent[]`, `record_digest`, `negotiation_history[]`, the +hold's status, `commitment.version`, `idempotency_key`); a seller assertion, an +informative figure, or an identifier minted downstream of agreement +(`availability`, `addressable_scale`, `match_rate_expected`, `derived_units`, +`deal_id_issuance`, `external_references[]`, `correlation_id`); or a value that +is not a term of the agreement: the seller's own figures (`seller_rate`, +`floor`; the transacted rate is `agreed_rate`) and buyer artifacts delivered +after agreement (`assets[]`). The requirement on creative (`formats`, `specs`, +`creative_policy`, `materials_due`) is a term. + +Every other value is inside the record: `seller`, `buyer`, and `issued_from`; every +`seller-set` field, including prose (`description`, `specifications`, `content_detail`, +`creative_policy`, `specs`); every option space (`available[]`, `included[]`, `deltas`, +`max_select`, `allowed[]`, `min` / `max`); every value the buyer writes under +§ 3 (selections, `settable` values, `commitment`, data under `supplied` fields +other than `assets[]`, any resolved `requestable` ask); `pricing[].agreed_rate`; +and `hold_available`, `hold_duration`, `hold_scope`. + +**Digest input.** The record is computed over the composed record as the parties +exchange it: the Full Representation of `version` as the seller published it to +this buyer, with the buyer's § 3 writes applied. Each party computes it over the +document it transmits or receives, never over its internal store; a value +present in a store but absent from the exchanged document is, by construction, +not a term. A `derived` value inside the record is computed by the rule this +specification states for it; where no rule exists, the two parties' digests +differ and the line item is not agreed. + +**Canonical form.** The digest input is the RFC 8785 (JSON Canonicalization +Scheme) serialization of the binding record, encoded as UTF-8. This +specification names no wire format; for this section JSON is the data model, and +a document carried as YAML is converted to JSON under the YAML 1.2 core schema +before canonicalization. A document whose JSON form contains duplicate object +keys is invalid. Strings, timestamps included, are hashed as written; a party +MUST NOT reformat a value it did not write. Numbers serialize as RFC 8785 +§ 3.2.2.3 prescribes (IEEE-754 double precision, shortest round-trip form). A +producer MUST NOT write a Number that this serialization does not reproduce as +the decimal value written; a Number with at most 15 significant digits inside +the range of a double always does, and a document containing one that does not +is invalid for this section. `24.00` and `24` are the same value. + +`record_digest` is the `Digest` (§ 2.2) of the digest input. + +**Two values.** On a shared proposal no buyer-written value is present, and +the digest identifies the offered terms of that version: what a cache or a +manifest entry compares. On a composed record the +buyer's writes are present, and the digest identifies what this buyer and the +seller agree to. An `assent[]` entry always carries the second. A shared proposal +carries no `assent[]`. + +A receiving party MUST recompute `record_digest` and compare it byte-for-byte +before relying on any `assent[]` entry. A `record_digest` asserted in a document +is a convenience, not evidence. + +#### 4.1.2 Assent + +- `assent[]` is carried on the composed record exchanged between one buyer and + the seller. A proposal MAY carry any number of entries. A line item MUST NOT + be named by more than one live buyer `commitment` entry, nor by more than one + live seller `acceptance` entry. An entry is live until it is void or replaced + under the following rules. +- The parties are the record's `seller` and `buyer` (§ 4.1). `role` names what + the entry does, and it implies the party. `role: commitment` is written by the + buyer. Its `line_item_ids[]` MUST be non-empty, MUST name line items whose + `commitment` object (§ 5.6.1) is populated in the record, and on their own MUST + meet linked execution and each named line item's `requires` (§ 5.6.3). + `role: acceptance` is written by the seller and names the `version`, + `record_digest` and `line_item_ids[]` the seller accepts. Neither entry refers + to the other, and the order in which they are written is not constrained. +- `record_digest` in an entry is the digest of the binding record for the line + items in that entry's `line_item_ids[]` (§ 4.1.1). It equals the + document-level `record_digest` when the document contains exactly those line + items. `version` in an entry is the `version` of the record the digest was + computed over; because `version` is outside the binding record, the entry + binds it explicitly. +- The line items of a buyer `commitment` entry are `agreed` when, and only when, + `assent[]` contains a live seller `acceptance` entry with equal `version`, + equal `record_digest` and equal `line_item_ids[]`, and both signatures verify + under the AAMP agent identity and signing profile. `line_item_ids[]` compare as + sets. Equal digests already imply + equal line item sets; the explicit condition rules out partial acceptance. A + line item for which no such pair exists is not agreed, whatever its `status` + says. +- A seller MUST NOT write an `acceptance` entry after the proposal's + `offer_window.end`, nor for a `commitment` entry it receives after the + `price_valid_until` of the committed `pricing[]` entry of any line item the + `commitment` entry names. + Such a `commitment` entry is void, and so is one unmatched when `offer_window.end` + passes. A `negotiation_history[]` entry at the same `version` with + `actor: buyer, action: withdrawn` or `actor: seller, action: declined`, + written after the `commitment` entry, also voids it. A void entry stays in + `assent[]`, does not count toward `agreed`, and the line items it names MAY + appear in a new `commitment` entry. +- A later entry from the same party whose `version`, `record_digest` and + `line_item_ids[]` equal those of one of its live entries replaces that entry + (a retry after a lost response). A later entry from the same party that names + a line item already in one of its live entries but differs in any of the three + is invalid, and the earlier entry stands. +- The signing input of an entry is the RFC 8785 serialization of the entry with + `signature` removed. `signature` is a detached signature over that input in + the container the AAMP agent identity and signing profile defines, carrying + the key identifier and algorithm. `signer` identifies the agent that signed; + if the container also names the signer, that name MUST equal `signer`. The + form of `signer`, the container and algorithms, key resolution, and how + `signer` is bound to the party `role` implies are defined by the profile. + This specification requires of the profile only that (a) verification needs + only the stored record and key material the signer has published, or a + retained copy of it, and MUST NOT require an interactive request to the + counterparty or a registry lookup; (b) the signature is bound to this artifact + type, so an assent signature cannot be presented as a signature over any other + AAMP artifact; and (c) a signature remains verifiable for the life of the + agreement, including after the signing key is rotated. +- Transitional rule. Until the profile is published, `signer` and `signature` + MAY be absent. An entry without them is an unsigned assent record whose + authenticity rests on the authenticated channel it arrived over, and the + `agreed` rule applies without the signature condition. +- Adding an `assent[]` entry does not change `version`. +- `agreed` is terminal for a line item. Every `seller-set`, `selectable`, + `settable`, and `requestable` value of it and its `pricing[].agreed_rate` MUST + remain as in the version `assent[]` names. A later version of the proposal does + not touch the agreed record: a party verifies an entry over the version it + names (§ 4.1.3), not the current one. A change to a value inside the record is + applied outside it, in the downstream standard the line item lands in (§ 1.1), + against the agreed record as its baseline. + +#### 4.1.3 Offered terms and versions + +**Offered terms.** The offered terms of a version are every value the seller +writes: `immutable` and `seller-set` fields; the seller-declared structure of +`selectable`, `settable` and `requestable` fields (`available[]`, `included[]`, +`deltas`, `max_select`, `allowed[]`, `min`, `max`, declared defaults and +ceilings); and the set of `line_item_id`s the version carries. The following +are not offered terms and MAY change without a new version: `status` (proposal +and line item), `assent[]`, `negotiation_history[]`, `availability`, +`addressable_scale`, the hold's status (every `hold_status` key except +`hold_available`, `hold_duration` and `hold_scope`, which offer the hold), +`deal_id_issuance`, `external_references[]`, `correlation_id`, +`assets[].approval_status`; `pricing[].seller_rate`, which is what the publisher +receives and not a term offered to the buyer; every value the buyer writes +(`supplied` fields, `commitment`, selections and values on `selectable` and +`settable` fields, and a `requestable` ask); and every `derived` value computed +from a buyer's writes (`pricing[].agreed_rate`, `commitment.derived_units`). A +buyer composes against a named `version` and never increments it. + +**Freezing.** A seller MAY edit a proposal in place only while its current +version is not yet frozen (§ 4.1). Two representations of the same +`(proposal_id, version)` whose offered terms differ, compared on the fields and line +items both carry, are non-conformant. A line item that +appears in a frozen version MUST NOT be removed from the proposal; it leaves the +shelf by its `status` (§ 5.1), which is a lifecycle change. Adding a line item +changes the offered terms. + +**Versions.** Each change to the offered terms of a frozen version MUST create +version `n+1` and MUST append exactly one `negotiation_history[]` entry carrying +that `version`, with `actor: seller` and `paths[]` naming every offered-terms +field whose value differs from version `n`. Its `action` is one of +the following: + +- `published` for version 1, appended no later than the moment version 1 is + frozen, and for a change the seller makes on its own initiative, including a + change to `seller-set` prose. +- `granted` or `countered` for a change that answers a buyer's `requestable` + ask: `granted` puts the requested value into force, `countered` puts a different + value into force in its place. + +Only the seller changes offered terms, so only these entries create versions. +Every other entry (`requested`, `declined`, `withdrawn`) carries the version it +refers to. + +**Staleness.** An ask or a hold is on a `version`. It is *stale* when the binding +record (§ 4.1.1) for the line items it names, with any buyer writes applied, +differs between that `version` and the current one. `version` is outside the +record, so a revision that touches only another line item does not make it stale, +and a change to a proposal-level term does. Under `linked_execution: all`, a change +to the set of `line_item_id`s also makes it stale. The seller computes the test +from the versions it retains, and no digest is published for it. For a `commit`, +the seller recomputes `record_digest` over the line items as they stand plus the +buyer's writes. A value equal to the one in the buyer's `assent[]` entry means the +seller MAY accept, at the `version` that entry names. + +**Retention.** A seller MUST keep every version that a `negotiation_history[]` +entry, a `commitment` or an `assent[]` entry names retrievable by +`(proposal_id, version)` for as long as the proposal is retrievable. This +specification does not define how a party fetches a version. ### 4.2 Agent comprehension @@ -589,6 +845,123 @@ The case a buying agent reads to decide whether to look further. | `not_suitable_for[]` | Array[String] | OPTIONAL | seller-set | Advisory exclusion guidance. Non-normative | | `seasonality_notes` | String | OPTIONAL | seller-set | When the proposal performs differently | +### 4.3 Lifecycle + +**Shared and issued.** A proposal with no `buyer` is *shared*. A proposal with a +`buyer` is *issued* to that buyer. The seller creates one with `publish`: a new +`proposal_id`, version 1, `buyer`, and, when it is made from a shared proposal, +`issued_from`. A `custom` proposal is issued from the start. `buyer` is immutable, +so every later version stays with that buyer. Each issued proposal has an +independent `status`, `version` and `negotiation_history[]`. + +A buyer that wants an issued proposal sends, over the binding, the ask, hold, or commit +it would make, naming the shared proposal and `version` it read, or a brief. A seller +that issues one answers with `publish`, and the buyer sends its ask, hold, or commit +against the new `proposal_id`. + +Asks, `under_review`, holds, and assent live on a proposal issued to one buyer; a +shared proposal, or any other record more than one buyer can read (§ 5.8), carries +none of them. + +**Asks.** A buyer `request` (`requestable`, not `hold_status`) or `commit` +(§ 5.6.1) is an *ask*: it names a `version` and stays *open* until answered or +withdrawn, or until it goes stale (§ 4.1.3) or void (§ 4.3.2, § 4.3.4). A `commit` is one +act: the buyer's `assent[]` entry and the `commitment` of each line item that entry +names. A `request` is identified by its `line_item_id`, `pointer`, and `requested_value` +(§ 4.1). Two equal ones are one ask while that ask is open, and an equal `request` after +it ends is a new ask. Holds change neither `status` nor `negotiation_history[]`. + +#### 4.3.1 Interactions + +| Interaction | Actor | Effect; history `action` | +| :--- | :--- | :--- | +| `publish` | seller | version 1 of a new or `draft` proposal, or the next `version` for a change to offered terms (§ 4.1.3); `published`. Moving a `draft` line item in a frozen version: neither (§ 4.3.2) | +| `request` | buyer | asks a `requestable` value on one line item against a named `version`, stated as a `HistoryPath` (§ 4.1) with `requested_value`; `requested` | +| `grant` | seller | of a `request`: new `version` with the requested value in force as an offered term; `granted` | +| `counter` | seller | of a `request`: new `version` with another value; `countered` | +| `decline` | seller | no new `version`; `declined` | +| `commit` | buyer | `commitment` (§ 5.6.1) pinned to `version`, with the buyer's `assent[]` entry; no entry | +| `accept` | seller | of a `commit`: the seller's `assent[]` entry, no new `version`; no entry | +| `withdraw` | seller: line item or proposal; buyer: its own ask | `withdrawn`. A seller's is one entry. For each line item it moved to `withdrawn`, its `paths[]` holds a `HistoryPath` with that `line_item_id` and the `pointer` `/status` | +| hold request, grant, decline, release | buyer; seller; seller; buyer, or seller on a revision (rule 2) | `hold_status` only; no entry | + +A buyer that turns down an offer, including a counter, takes no action and leaves no entry. + +#### 4.3.2 Line-item status transitions + +A line item moves between `status` values as follows. These transitions are +exhaustive, so a status with none leading out of it is terminal. + +- `draft` → `draft` at a new `version`: seller `publish`. +- `draft` → `published`: seller `publish`. In a frozen version this changes only `status`, + so it makes no new `version` and no entry (§ 4.1.3). +- `draft` → `withdrawn`: seller. +- `published` → `published` at a new `version`: seller `publish`. +- `published` → `under_review`: buyer ask. +- `under_review` → `under_review`: another buyer ask, a seller or buyer act that + ends one ask while another stays open, or a seller `publish` that makes no open + ask stale. +- `under_review` → `published`: the last open ask ends by seller `grant`, + `counter`, or `decline`, by buyer `withdraw`, or by going stale on a new + `version` (rule 2). +- `under_review` → `agreed`: seller `accept` of a `commit` (§ 4.1.2); open asks are void. +- `published` | `under_review` → `sold_out`: seller, never while `held`; open + asks are void. +- `published` | `under_review` | `sold_out` → `withdrawn`: seller, never while + `held`, nor until the latest `price_valid_until` of its `pricing[]` entries; open + asks are void. +- `sold_out` → `published`: seller, when quantity returns, with no new `version`, + because `availability` is not an offered term. +- `published` | `under_review` | `sold_out` → `expired`: `offer_window.end` passes + (§ 4.3.4). + +A *firm entry* is a `pricing[]` entry with a `price_valid_until`. It holds until +that date or `offer_window.end`, whichever comes first. While it holds, a seller +revision MUST NOT change that entry, and MUST NOT end it sooner by moving +`offer_window.end`. A revision that narrows who may commit (§ 5.8) does not apply, +while the entry holds, to a buyer that was eligible when the entry was first offered. +The seller of an issued proposal MUST answer a resolved `commit` under it that arrives +while the entry holds, on or after `offer_window.start`, and passes the checks of +§ 5.6.1 and any authority check the seller applies (§ 5.8): accept it, or decline it +on an allowed ground. The allowed grounds are lack of availability, an exclusivity +conflict (§ 5.6), and a ground stated in the `terms_ref` in force when the entry was +first offered. `sold_out` is lack of availability: a `commit` that arrives while the +line item is `sold_out` returns `not_committable` (§ 2.4). + +Two rules apply: + +1. A stale ask (§ 4.1.3) is not open and not evaluated. The seller rejects it with + `stale_version` (§ 2.4). +2. A seller MAY revise a line item while an ask is open or a hold runs. The + revision is a new `version` (§ 4.1.3). An ask it makes stale ends (rule 1), and + the seller MUST release a `held` hold it makes stale or re-offer it at the new + `version`, where `state` stays `held`. A `commit` MUST NOT carry a `requestable` + ask: `accept` makes no new `version`, so another value takes a separate `request`. + +#### 4.3.3 Proposal status + +A proposal's `status` is the first of the following that applies: + +1. `draft` when every line item is `draft`. +2. `under_review` when any line item is. +3. `published` when any line item is `draft`, `published` or `sold_out`. +4. `agreed` when any line item is `agreed`. +5. Otherwise `expired` when any line item is `expired`, else `withdrawn`. + +A seller `withdraw` of the proposal moves to `withdrawn` every line item that +§ 4.3.2 lets the seller withdraw. + +#### 4.3.4 Expiry + +| Instant | Ends | Effect when passed | +| :--- | :--- | :--- | +| `offer_window.end` | the offer window | every `published`, `under_review`, or `sold_out` line item is `expired`; open asks are void | +| `pricing[].price_valid_until` | the firm entry | `status` unchanged; the seller MUST NOT accept a later `commit` under that entry, and SHOULD `publish` or `withdraw` | +| `hold_status.hold_expires_at` | the hold | `hold_status.state` becomes `expired`; `status` unchanged | + +`hold_expires_at` MUST NOT exceed `offer_window.end` or the earliest +`pricing[].price_valid_until`. + --- ## 5. Line items @@ -601,7 +974,7 @@ channel. | 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 | +| `status` | LineItemStatus | REQUIRED | lifecycle | Independent of the proposal's status, so one line item may sell out while the rest of the shelf stays live | | `channel` | MediaChannel | REQUIRED | immutable | Core terms in § 5.1.1. The discriminator for how the rest of the line item is read | | `ext` | Ext | OPTIONAL | seller-set | See Extensions (§ 2.5) | @@ -706,7 +1079,7 @@ which 5.1.1 gives the line item's `channel`. | `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 | +| `approval_status` | ApprovalStatus | OPTIONAL | The seller's review outcome. The seller moves it | lifecycle | **`CreativeDelivery`** keys: @@ -832,7 +1205,7 @@ exclusions: | `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[].price_valid_until` | Instant | OPTIONAL | seller-set | How long the entry holds (§ 4.3.2). 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 | @@ -843,13 +1216,24 @@ exclusions: | `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 | +| `requires` | Requires | OPTIONAL | seller-set | Conditions that must hold in the same `commit` for this line item to be committable. See 5.6.3 | #### 5.6.1 commitment -Absent on a published standard proposal. Populated during composition. A -`commitment` resolves under § 3 only when the buyer is eligible (§ 5.8). Frozen -when status becomes `agreed`. +Absent on a shared proposal (§ 4.3). Populated during composition. A +`commitment` resolves under § 3 when the buyer is eligible (§ 5.8), and the +commitment fits `commitment_bounds` and `min_spend`, is not stale (§ 4.1.3), +and meets `requires` (§ 5.6.3). A resolved `commitment` is an offer to buy. It +binds only when the seller accepts it; +`assent[]` (§ 4.1) records that acceptance for the binding `version`. A seller +MAY decline a resolved `commitment`; the buyer MAY recompose. A decline for lack +of availability MUST return `availability` recomputed against the buyer's +composition; the seller's determination is authoritative. While a buyer's hold +(§ 5.6.2) runs, the seller MUST NOT decline that buyer's `commitment` for lack of +availability. This does not cover inventory that a hold granted ahead of it +(§ 5.6.2) reserves or took on converting. A firm entry limits a +decline to the grounds in § 4.3.2. Acceptance ends the availability check; booking +is downstream (§ 1.1). Frozen when the line item's `status` becomes `agreed`. **`CommitmentAmount`** is the committed figure. `basis` means only this. @@ -873,19 +1257,20 @@ when status becomes `agreed`. An `idempotency_key` is opaque and scoped to one buyer and seller pair. The seller MUST NOT interpret it. -The seller MUST reject a new commitment attempt whose `version` is not the -current version of the proposal named by `proposal_id`, whose proposal is past -`offer_window.end`, or where the `pricing[]` entry the commitment is priced against +The seller MUST reject a new commitment attempt that is stale (§ 4.1.3), whose +proposal is past `offer_window.end`, or where the `pricing[]` entry the commitment is priced against is past its `price_valid_until`. The rejection MUST carry `stale_version`, or `expired` when `offer_window.end` or `price_valid_until` has passed, so the buyer can fetch it again. These checks MUST be deterministic: they depend only on the -stored proposal and the time the seller receives the attempt. +stored proposal and the time the seller receives the attempt. A seller that +rejects an attempt before `offer_window.start` returns `not_committable` with +`retry: same`. For a replay from the same buyer of a commitment attempt the seller has already processed, with the same `idempotency_key` and the same body (every value the buyer sent with the attempt except `idempotency_key`), the seller MUST return the original result, whether an acceptance or a rejection, and -MUST NOT create a second commitment. The version and expiry checks apply to a +MUST NOT create a second commitment. The staleness and expiry checks apply to a new attempt, not to returning a stored result. An error whose `retry` is `same` is not a stored result, so the retry runs the checks again. Reusing an `idempotency_key` with a different body MUST return `idempotency_conflict` and @@ -914,8 +1299,8 @@ Transitions: `none` → `requested` → `held` → (`expired` | `released` | | 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 | +| `all_of[]` | Array[String] | OPTIONAL | Line items that MUST all be in the same `commit` | +| `any_of[]` | Array[String] | OPTIONAL | Line items of which at least one MUST be in the same `commit` | | `min_commitments[]` | Array[MinCommitment] | OPTIONAL | Purchase-level thresholds: `{ line_item_id, basis, amount, currency }` | ```yaml @@ -933,13 +1318,13 @@ 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` +- A seller MUST reject a `commit` that includes a line item whose `requires` is unmet. An unmet `requires` returns `not_committable`. 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`. + before a `commit`, and the line items left can't meet `requires`, 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`. ### 5.7 Guarantees @@ -1019,8 +1404,8 @@ such as a line item's `requires` (§ 5.6.3) or a `negotiation_history[]` path. A buyer that fails these rules, or an authority check the seller applies (whether the sender may make this request for `buyer`, or `buyer` for an advertiser it names, § 1.2), gets `not_eligible` (§ 2.4), or `not_found` when the requester may not read what the request -names. That is a rejection, not a decline (§ 2.4). A mandate is not a field of this -object. +names. That is a rejection, not a decline, so the limits on a decline (§ 4.3.2, +§ 5.6.1) do not apply. A mandate is not a field of this object. **`RequiredCredential`** keys: @@ -1043,7 +1428,7 @@ and signing profile (provisional name). They are not expressed through | `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 | +| `deal_id_issuance` | String | OPTIONAL | derived | When and how the deal id is minted. Issued on acceptance of the commitment (§ 5.6.1) | | `buyer_requirements` | BuyerRequirements | OPTIONAL | mixed | See below | | `serving_platform` | String | OPTIONAL | seller-set | Where the line item serves | @@ -1104,20 +1489,18 @@ Runnable copies of every example below live in [`examples/`](../examples/). ### 6.1 Proposal -See [`examples/proposal.yaml`](../examples/proposal.yaml). Its line items are +Shows version 2 after one seller revision (§ 4.1.3). 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 buyer commitment, and -`settable` bounds. See +Shows `selectable` audiences with per-option pricing and `settable` bounds. See [`examples/line-item-display.yaml`](../examples/line-item-display.yaml). ### 6.3 CTV line item -Shows a buyer commitment naming the `proposal_id` and `version` it was priced -against (§ 5.6.1). See -[`examples/line-item-ctv.yaml`](../examples/line-item-ctv.yaml). +See [`examples/line-item-ctv.yaml`](../examples/line-item-ctv.yaml). Inspecting this line item's VAST creative is subject to § 5.4.1. @@ -1137,6 +1520,14 @@ 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). +### 6.12 Issued proposal, partly agreed + +A proposal issued to one buyer from the shared proposal of § 6.1 (§ 4.3). One line +item is agreed at version 1, with a commitment. The other stays `published` at +version 2, with a hold, so the proposal stays `published` (§ 4.3.3). The `assent[]` +entries name version 1, and the digest is over that line item alone (§ 4.1.2). See +[`examples/proposal-issued.yaml`](../examples/proposal-issued.yaml). + --- ## 7. Why this split @@ -1163,9 +1554,10 @@ display line item is `registry_gated`, so it is not listed. See - **Template:** seller-side only. It lives in the seller's own systems (its proposal store), is not part of the public object, and is never transacted directly. -- **Issued proposal:** what buyers see and commit against. Each has its own +- **Issued proposal:** what a buyer commits against (§ 4.3). Each has its own `proposal_id` per counterparty or destination, and may carry - `proposal_template_id` (§ 4.1) to trace it back to its template. + `proposal_template_id` (§ 4.1) to trace it back to its template and `issued_from` + to trace it back to a shared proposal. - Buyer-specific terms, such as annual rates and named visibility, live on the issued proposal's line items: `visibility: named`, `eligible_buyers[]` and their own `pricing[]`. @@ -1226,14 +1618,16 @@ body is normative.** | `version` | 4.1 Identity and lifecycle | Integer | REQUIRED | derived | | `spec_version` | 4.1 Identity and lifecycle | String | REQUIRED | seller-set | | `seller` | 4.1 Identity and lifecycle | Party | REQUIRED | immutable | -| `buyer` | 4.1 Identity and lifecycle | Party | REQUIRED when `assent` is present | immutable | +| `buyer` | 4.1 Identity and lifecycle | Party | REQUIRED when `type` is `custom` or `assent[]` is present | immutable | | `type` | 4.1 Identity and lifecycle | Enum | REQUIRED | immutable | | `proposal_template_id` | 4.1 Identity and lifecycle | String | OPTIONAL | immutable | -| `status` | 4.1 Identity and lifecycle | Enum | REQUIRED | derived | -| `assent` | 4.1 Identity and lifecycle | Object | OPTIONAL | derived | +| `issued_from` | 4.1 Identity and lifecycle | VersionRef | OPTIONAL | immutable | +| `status` | 4.1 Identity and lifecycle | ProposalStatus | REQUIRED | derived | +| `record_digest` | 4.1 Identity and lifecycle | Digest | OPTIONAL | derived | +| `assent[]` | 4.1 Identity and lifecycle | Array[AssentEntry] | OPTIONAL | recorded | | `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 | +| `negotiation_history[]` | 4.1 Identity and lifecycle | Array[HistoryEntry] | REQUIRED when the current `version` is frozen (§ 4.1.3) | recorded | | `line_items[]` | 4.1 Identity and lifecycle | Array[LineItem] | REQUIRED | seller-set | | `linked_execution` | 4.1 Identity and lifecycle | Enum | OPTIONAL | seller-set | | `ext` | 4.1 Identity and lifecycle | Ext | OPTIONAL | seller-set | @@ -1243,7 +1637,7 @@ body is normative.** | `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 | +| `status` | 5.1 Identity | LineItemStatus | REQUIRED | lifecycle | | `channel` | 5.1 Identity | MediaChannel | REQUIRED | immutable | | `ext` | 5.1 Identity | Ext | OPTIONAL | seller-set | | `properties` | 5.2 Inventory composition | Selectable[PropertyOption] | OPTIONAL | selectable | @@ -1312,22 +1706,22 @@ body is normative.** | Marker | Count | Meaning | Fields | | :--- | :--- | :--- | :--- | -| immutable | 8 | Set once at creation. Neither party may change it. | `proposal_id`, `seller`, `buyer`, `type`, `proposal_template_id`, `brief_id`, `line_item_id`, `channel` | -| seller-set | 39 | The seller sets it and MAY revise it while status is `draft` or `published`. The buyer cannot change it directly. | `spec_version`, `offer_window`, `line_items[]`, `linked_execution`, `ext` (proposal), `description`, `specifications`, `best_for[]`, `not_suitable_for[]`, `seasonality_notes`, `ext` (line item), `content_detail`, `specs`, `third_party_tags`, `creative_delivery`, `approval`, `exclusions_accepted`, `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_buyers[]`, `eligible_seats[]`, `terms_acceptance_required`, `terms_ref`, `buying_route`, `serving_mode`, `serving_platform`, `agreement`, `billing`, `supported_order_systems[]` | +| immutable | 9 | Set once at creation. Neither party may change it. | `proposal_id`, `seller`, `buyer`, `type`, `proposal_template_id`, `issued_from`, `brief_id`, `line_item_id`, `channel` | +| seller-set | 39 | The seller sets it and MAY revise it while the `status` of the object that carries it is `draft`, `published` or `under_review`. § 4.3.2 states what a revision does to a line item `under_review`. The buyer cannot change it directly. | `spec_version`, `offer_window`, `line_items[]`, `linked_execution`, `ext` (proposal), `description`, `specifications`, `best_for[]`, `not_suitable_for[]`, `seasonality_notes`, `ext` (line item), `content_detail`, `specs`, `third_party_tags`, `creative_delivery`, `approval`, `exclusions_accepted`, `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_buyers[]`, `eligible_seats[]`, `terms_acceptance_required`, `terms_ref`, `buying_route`, `serving_mode`, `serving_platform`, `agreement`, `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` | | supplied | 1 | The buyer provides data or an artifact. The seller does not pre-populate it. | `exclusions` | | 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` | +| derived | 6 | Computed by a rule this specification states. Neither party sets it directly. | `version`, `status` (proposal), `record_digest`, `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[]` | +| recorded | 3 | Written by the party that acted. Append-only: an entry is never edited or removed. Not a term. | `assent[]`, `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 83 fields, **20 are touched by the buyer** — 12 `selectable`, 5 `settable`, 1 -`supplied` and 2 `requestable`. The remaining 63 are `seller-set`, `immutable`, +Of 85 fields, **20 are touched by the buyer** — 12 `selectable`, 5 `settable`, 1 +`supplied` and 2 `requestable`. The remaining 65 are `seller-set`, `immutable`, `derived`, `lifecycle`, `recorded`, `asserted` or `mixed`. Only the 2 `requestable` fields require a round trip to resolve (§ 5.6.1). From f2591e192c0c93f2fa98f6a31e00118142082ac3 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Wed, 4 Nov 2026 01:15:10 +0100 Subject: [PATCH 33/47] Define what the buyer writes and how the rate is computed Add selected[] as the complete set of options in effect and value as the one buyer key on a settable field, with an explicit default. An envelope's value narrows allowed[], and the bare-value fallbacks go. Every object option has an id, unique in its field. Name BuyerWrites, LineItemWrites, and the availability read. Give agreed_rate one formula, on the committed entry only, rounded once. Rename gross_rate to list_rate, floor to min_rate, a hint, and delta_pct to delta_fraction, a fraction. Make list_rate and commitment_bounds requestable, mark commitment mixed, and give transaction_mechanism max_select 1. Split geo into a settable geo and a selectable markets over GeoCode, type device as AdCOM codes and daypart as ClockWindow, and delete supported_formats. Update the examples, the issued record_digest, the appendices, and the example checker. --- .github/ISSUE_TEMPLATE/comment.yml | 2 +- examples/line-item-audio.yaml | 14 +- examples/line-item-ctv-bonus.yaml | 7 +- examples/line-item-ctv.yaml | 6 +- examples/line-item-display.yaml | 30 ++- examples/manifest.json | 23 +- examples/proposal-issued.yaml | 31 ++- scripts/check_appendix.py | 2 - scripts/check_examples.py | 177 +++++++++++-- spec/CHANGELOG.md | 24 ++ spec/openproposal-3.0.md | 410 +++++++++++++++++++---------- 11 files changed, 533 insertions(+), 193 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/comment.yml b/.github/ISSUE_TEMPLATE/comment.yml index 2549c24..640c20e 100644 --- a/.github/ISSUE_TEMPLATE/comment.yml +++ b/.github/ISSUE_TEMPLATE/comment.yml @@ -47,7 +47,7 @@ body: attributes: label: Field(s) involved description: Comma-separated, if your comment is about specific fields. - placeholder: "pricing[].floor, transaction_mechanism" + placeholder: "pricing[].min_rate, transaction_mechanism" - type: textarea id: comment attributes: diff --git a/examples/line-item-audio.yaml b/examples/line-item-audio.yaml index 746d544..fc6fe28 100644 --- a/examples/line-item-audio.yaml +++ b/examples/line-item-audio.yaml @@ -14,17 +14,17 @@ production: included: ["buyer_supplied"] deltas: { seller_built: { delta_flat: 12000.00 } } -geo: # priced by market (§ 5.5) - available: ["USA", "CAN", "GBR"] - included: ["USA"] - deltas: { "CAN": { delta_cpm: 1.50 }, "GBR": { delta_cpm: 3.00 } } +markets: # priced by market (§ 5.5) + available: ["US", "CA", "GB"] + included: ["US"] + deltas: { "CA": { delta_cpm: 1.50 }, "GB": { delta_cpm: 3.00 } } max_select: 3 -frequency_cap: { count: { min: 1, max: 6 }, period: day, per: listener } +frequency_cap: { count: { min: 1, max: 6, default: 3 }, period: day, per: listener } time_zone: viewer_local pricing: - - { cost_method: CPM, gross_rate: 18.00, floor: 15.84, + - { cost_method: CPM, list_rate: 18.00, min_rate: 15.84, price_valid_until: "2026-12-15T23:59:59Z", currency: USD } commitment_bounds: { min_budget: 5000.00 } @@ -32,6 +32,6 @@ commitment_bounds: { min_budget: 5000.00 } 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"], max_select: 1 } 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 index 0df595e..18ec77e 100644 --- a/examples/line-item-ctv-bonus.yaml +++ b/examples/line-item-ctv-bonus.yaml @@ -10,7 +10,8 @@ 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 } +frequency_cap: { count: { min: 1, max: 2, default: 1 }, period: day, per: household } +time_zone: viewer_local requires: all_of: ["business-daily-display", "weather-app-ctv"] @@ -18,12 +19,12 @@ requires: - { line_item_id: "business-daily-display", basis: spend, amount: 50000.00, currency: USD } pricing: - - { cost_method: FLAT, gross_rate: 0.00, + - { cost_method: FLAT, list_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"] } +transaction_mechanism: { available: ["private_auction"], included: ["private_auction"], max_select: 1 } serving_mode: ssai diff --git a/examples/line-item-ctv.yaml b/examples/line-item-ctv.yaml index 71050bd..56c57ec 100644 --- a/examples/line-item-ctv.yaml +++ b/examples/line-item-ctv.yaml @@ -9,11 +9,11 @@ formats: { available: ["ctv/instream-15s", "ctv/instream-30s"], deltas: { "ctv/instream-30s": { delta_cpm: 8.00 } } } creative_delivery: { method: seller_hosted, protocol: vast_4_2, secure_required: true } -frequency_cap: { count: { min: 1, max: 4 }, period: day, per: household } +frequency_cap: { count: { min: 1, max: 4, default: 3 }, period: day, per: household } time_zone: viewer_local pricing: - - { cost_method: CPM, gross_rate: 45.00, floor: 39.60, + - { cost_method: CPM, list_rate: 45.00, min_rate: 39.60, price_valid_until: "2026-12-01T23:59:59Z", currency: USD } commitment_bounds: { min_budget: 25000.00 } @@ -24,7 +24,7 @@ hold_status: { hold_available: true, hold_duration: "P14D", 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"], max_select: 1 } serving_mode: ssai correlation_id: "ep-q4-2026-commitment/weather-app-ctv" diff --git a/examples/line-item-display.yaml b/examples/line-item-display.yaml index bc1f78e..e203f42 100644 --- a/examples/line-item-display.yaml +++ b/examples/line-item-display.yaml @@ -45,8 +45,14 @@ formats: creative_delivery: { method: buyer_hosted, protocol: javascript_tag, secure_required: true } materials_due: { at: "2026-09-24T23:59:59Z" } -geo: { allowed: ["US-NY", "US-CA", "US-IL", "US-TX"] } -frequency_cap: { count: { min: 1, max: 5 }, period: day, per: device } +geo: + allowed: ["US-NY", "US-CA", "US-IL", "US-TX"] + default: { include: ["US-NY", "US-CA", "US-IL", "US-TX"] } +device: { allowed: [1, 2, 4, 5], default: { include: [1, 2] } } # AdCOM 1.0 Device Types; 1 covers 4 and 5 +daypart: + allowed: [{ days: [monday, tuesday, wednesday, thursday, friday], start: "06:00", before: "22:00" }] + default: { include: [{ days: [monday, tuesday, wednesday, thursday, friday], start: "06:00", before: "22:00" }] } +frequency_cap: { count: { min: 1, max: 5, default: 3 }, period: day, per: device } time_zone: "America/New_York" exclusions_accepted: # what this seller can enforce (§ 5.5) @@ -56,9 +62,9 @@ exclusions_accepted: # what this seller can enforce (§ 5.5) pricing: - cost_method: CPM - gross_rate: 24.00 + list_rate: 24.00 seller_rate: 20.40 - floor: 21.00 + min_rate: 21.00 price_valid_until: "2026-11-15T23:59:59Z" currency: USD @@ -70,15 +76,15 @@ 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: "P30D" }, - { vendor: "DoubleVerify", attribution_window: "P30D" }] - included: ["IAS"] + available: [{ id: ias, vendor: "IAS", attribution_window: "P30D" }, + { id: dv, vendor: "DoubleVerify", attribution_window: "P30D" }] + included: ["ias"] shortfall_remedy: { available: ["makegood", "credit"], included: ["makegood"] } reporting: - available: [{ feed_type: aggregate, cadence: day, delivery_mechanism: api }, - { feed_type: log_level, cadence: day, delivery_mechanism: s3 }] - included: ["aggregate"] - deltas: { log_level: { delta_flat: 2500.00 } } + available: [{ id: daily-aggregate, feed_type: aggregate, cadence: day, delivery_mechanism: api }, + { id: daily-log-level, feed_type: log_level, cadence: day, delivery_mechanism: s3 }] + included: ["daily-aggregate"] + deltas: { daily-log-level: { delta_flat: 2500.00 } } visibility: registry_gated required_credentials: [{ credential_type: iab_registry, issuer: "registry.iabtechlab.ai" }] @@ -88,7 +94,7 @@ terms_ref: { url: "https://examplepub.example/legal/io-terms-2026.pd buying_route: PUBLISHER_AD_SERVER transaction_mechanism: { available: ["programmatic_guaranteed", "preferred_deal"], - included: ["programmatic_guaranteed"] } + included: ["programmatic_guaranteed"], max_select: 1 } serving_mode: client_side buyer_requirements: { seats_expected: true, allowed_dsps: ["dsp-a.example", "dsp-b.example"], diff --git a/examples/manifest.json b/examples/manifest.json index 7144fcf..53b413e 100644 --- a/examples/manifest.json +++ b/examples/manifest.json @@ -30,10 +30,29 @@ "audio/produced-30s" ] }, + "markets": { + "available": [ + "US", + "CA", + "GB" + ], + "included": [ + "US" + ], + "deltas": { + "CA": { + "delta_cpm": 1.5 + }, + "GB": { + "delta_cpm": 3.0 + } + }, + "max_select": 3 + }, "pricing": [ { "cost_method": "CPM", - "gross_rate": 18.0, + "list_rate": 18.0, "currency": "USD" } ], @@ -77,7 +96,7 @@ "pricing": [ { "cost_method": "CPM", - "gross_rate": 45.0, + "list_rate": 45.0, "currency": "USD" } ], diff --git a/examples/proposal-issued.yaml b/examples/proposal-issued.yaml index 21ee7eb..1396b90 100644 --- a/examples/proposal-issued.yaml +++ b/examples/proposal-issued.yaml @@ -20,11 +20,11 @@ description: > assent: - { role: commitment, version: 1, - record_digest: "sha256:3a91ba82c784a4c2f6800f0be97522cfaf755281729fbad43daab8d8bfe1dd2a", + record_digest: "sha256:cdcd75ebd66938fcd872cfc1bf66d10a3244758d7810125639beabe0dcf53430", line_item_ids: ["business-daily-display"], signed_at: "2026-10-05T10:14:03Z" } - { role: acceptance, version: 1, - record_digest: "sha256:3a91ba82c784a4c2f6800f0be97522cfaf755281729fbad43daab8d8bfe1dd2a", + record_digest: "sha256:cdcd75ebd66938fcd872cfc1bf66d10a3244758d7810125639beabe0dcf53430", line_item_ids: ["business-daily-display"], signed_at: "2026-10-05T10:31:47Z" } @@ -46,8 +46,10 @@ line_items: suppression_allowed: true suppression_list: { source: "advertiser_crm", size: 340000, match_method: "ramp_id" } formats: - available: ["display/300x250"] + available: ["display/300x250", "display/970x250"] included: ["display/300x250"] + deltas: { "display/970x250": { delta_cpm: 3.00 } } + selected: ["display/300x250", "display/970x250"] # the buyer's write exclusions_accepted: types: [domain, keyword] max_entries: { domain: 5000, keyword: 500 } @@ -55,10 +57,23 @@ line_items: exclusions: # supplied by the buyer domain: ["example-gambling.example"] keyword: ["crypto"] + geo: + allowed: ["US"] + default: { include: ["US"] } + value: { include: ["US"], exclude: ["US-TX"] } # the buyer's write + frequency_cap: { count: { min: 1, max: 5, default: 3, value: 4 }, period: day, per: device } + time_zone: "America/New_York" pricing: - - { cost_method: CPM, gross_rate: 24.00, agreed_rate: 24.00, + # agreed_rate: (24.00 + 3.00) x (1 + 0.02) = 27.54 (§ 5.6) + - { cost_method: CPM, list_rate: 24.00, agreed_rate: 27.54, price_valid_until: "2026-11-15T23:59:59Z", currency: USD } commitment_bounds: { min_budget: 10000.00, max_budget: 500000.00 } + cancellation_policy: + available: [{ id: "notice-14d" }, { id: "notice-7d" }] + included: ["notice-14d"] + deltas: { "notice-7d": { delta_fraction: 0.02 } } + max_select: 1 + selected: ["notice-7d"] # the buyer's write commitment: proposal_id: "examplepub-agencya-q4-2026-omni" version: 1 @@ -67,14 +82,14 @@ line_items: currency: USD flight: { start: "2026-10-12T00:00:00Z", end: "2026-12-21T23:59:59Z" } advertisers: [{ domain: "brand-a.example" }] - derived_units: 3333333 # derived: 80000.00 / 24.00 × 1000 + derived_units: 2904865 # derived: 80000.00 / 27.54 × 1000 idempotency_key: "agency-a-camp1-business-daily-display-8f42c1a9" hold_status: { hold_available: true, hold_duration: "P7D", hold_scope: line_item, state: none } visibility: named eligible_buyers: ["agency-a.example"] transaction_mechanism: { available: ["programmatic_guaranteed"], - included: ["programmatic_guaranteed"] } + included: ["programmatic_guaranteed"], max_select: 1 } - line_item_id: "weather-app-ctv" status: published @@ -85,7 +100,7 @@ line_items: included: ["ctv/instream-15s"] materials_due: { at: "2026-10-20T23:59:59Z" } # version 1 had "2026-10-13T23:59:59Z" pricing: - - { cost_method: CPM, gross_rate: 45.00, + - { cost_method: CPM, list_rate: 45.00, price_valid_until: "2026-12-01T23:59:59Z", currency: USD } commitment_bounds: { min_budget: 25000.00 } hold_status: { hold_available: true, hold_duration: "P14D", # re-offered at version 2 @@ -94,4 +109,4 @@ line_items: visibility: named eligible_buyers: ["agency-a.example"] transaction_mechanism: { available: ["programmatic_guaranteed"], - included: ["programmatic_guaranteed"] } + included: ["programmatic_guaranteed"], max_select: 1 } diff --git a/scripts/check_appendix.py b/scripts/check_appendix.py index 15b6b95..ccad4b9 100755 --- a/scripts/check_appendix.py +++ b/scripts/check_appendix.py @@ -68,7 +68,6 @@ "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", @@ -83,7 +82,6 @@ "`specs`": "waiting: the format spec", "`third_party_tags`": "waiting: the accepted tag protocols", "`approval`": "waiting: the review policy", - "`daypart`": "waiting: the clock window", "`exclusions_accepted`": "waiting: the accepted exclusion terms", "`exclusions`": "waiting: the exclusion lists", "`availability`": "waiting: the availability figure", diff --git a/scripts/check_examples.py b/scripts/check_examples.py index 8817403..ee898ca 100755 --- a/scripts/check_examples.py +++ b/scripts/check_examples.py @@ -10,7 +10,12 @@ - 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[]. + - Option names are unique, and included[], selected[], and deltas name options. + - selected[] or included[] fits max_select, and an empty set has a stated meaning + (section 3.1). + - A settable field holds its form, and each buyer value is within its bounds + (section 3.2). A line item declares geo or markets, not both (section 5.5). + - agreed_rate recomputes under section 5.6, on the committed entry only. - The manifest is a strict path subset of the full objects (section 2.1), and lists each public, published line item (section 2.3). - seller and buyer on a proposal hold legal_name (section 4.1). @@ -28,6 +33,7 @@ import json import re import sys +from decimal import ROUND_HALF_UP, Decimal from pathlib import Path import yaml @@ -43,16 +49,26 @@ # (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"), } +# Selectable fields whose row gives an empty set a meaning (section 3.1). +EMPTY_ALLOWED = set() +# The cost methods delta_cpm applies to (section 3.1). +IMPRESSION_METHODS = set() + + +def _committed_entry(item): + """The pricing[] entry the line item's commitment is priced against, if any.""" + entries = item.get("pricing") or [] + if not item.get("commitment"): + return None + return entries[0] if len(entries) == 1 else None + + # Each `REQUIRED when ` wording, as (object, line item, proposal) -> bool. CONDITIONS = { "`type` is `custom`": lambda o, i, p: o.get("type") == "custom", @@ -71,6 +87,9 @@ "`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, + "this is the committed entry": lambda o, i, p: _committed_entry(i) is o, + "`daypart` or `frequency_cap` is present": + lambda o, i, p: "daypart" in o or "frequency_cap" in o, } BASE = {"String", "Integer", "Number", "Boolean", "Enum", "Object"} @@ -85,6 +104,9 @@ 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") +# A GeoCode (section 5.5): ISO 3166-1 alpha-2, ISO 3166-2, or an extension term. +GEO_CODE = re.compile(rf"(?:[A-Z]{{2}}(?:-[A-Z0-9]{{1,3}})?|{TERM_DOMAIN}{TERM_PART})\Z") +CLOCK_TIME = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d\Z|24:00\Z") SIDE = ("buyer", "seller") PROPOSAL_STATUS = ("draft", "published", "under_review", "agreed", "withdrawn", "expired") CALENDAR_UNIT = ("hour", "day", "week", "month") @@ -96,6 +118,10 @@ "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)), + "GeoCode": (lambda v: isinstance(v, str) and GEO_CODE.match(v)), + "ClockTime": (lambda v: isinstance(v, str) and CLOCK_TIME.match(v)), + "DayOfWeek": (lambda v: v in ("monday", "tuesday", "wednesday", "thursday", "friday", + "saturday", "sunday")), "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://"))), @@ -193,6 +219,16 @@ def __init__(self): self._read_fields() self._read_key_tables() self._read_lists() + self._read_option_rules() + + def _read_option_rules(self): + """Sections 3.1 and 5.6: what a row says of its options and its deltas.""" + for key, row in self.line_item.items(): + if not row["type"].startswith("Selectable["): + continue + if re.search(r"[Nn]othing in effect", row["description"]): + EMPTY_ALLOWED.add(key) + IMPRESSION_METHODS.update(re.findall(r"`([A-Z]+)`", self.tables["Delta"]["delta_cpm"]["meaning"])) def _read_fields(self): target, in_table = None, False @@ -212,7 +248,7 @@ def _read_fields(self): elif in_table and target is not None and len(c) == 5 and c[0].startswith("`"): target[clean_key(c[0])] = { "type": c[1], "required": c[2], "marker": c[3], - "values": enum_values(c[1], c[4]), + "values": enum_values(c[1], c[4]), "description": c[4], } def _read_key_tables(self): @@ -234,6 +270,7 @@ def _read_key_tables(self): r = cells(self.lines[j]) rows[clean_key(r[0])] = { "type": r[1], "required": r[2], "values": enum_values(r[1], r[3]), + "meaning": r[3], } j += 1 current = None @@ -326,6 +363,9 @@ def check_type(self, file, path, value, typ): if base == "Settable": self.check_settable(file, path, value, args[0]) return + if base == "SettableRange": + self.check_settable_range(file, path, value, args[0]) + return if base == "Range": if not isinstance(value, dict) or not value or not value.keys() <= {"min", "max"}: self.err(file, path, "range", "a Range holds min, max, or both") @@ -375,18 +415,6 @@ def check_object(self, file, path, value, name): if key in value: self.check_value(file, f"{path}.{key}", value[key], row) - def check_settable(self, file, path, value, entry_type): - """Section 3.2: an enumerated set in allowed[], or a range in min and max.""" - if not isinstance(value, dict) or not value: - self.err(file, path, "settable", "expected a Settable") - return - if "allowed" in value: - if value.keys() != {"allowed"}: - self.err(file, path, "settable", "a Settable declares allowed[] or min and max") - self.check_type(file, f"{path}.allowed", value["allowed"], f"Array[{entry_type}]") - elif not value.keys() <= {"min", "max"}: - self.err(file, path, "settable", "a Settable declares allowed[] or min and max") - def check_selectable(self, file, path, value, option_type): if not isinstance(value, dict): self.err(file, path, "selectable", "expected a Selectable") @@ -401,22 +429,127 @@ def check_selectable(self, file, path, value, option_type): names = [] for i, option in enumerate(value["available"]): if isinstance(option, dict): + if "id" not in option: + self.err(file, f"{path}.available[{i}]", "id", "an object option has an id") names.append(option.get("id")) if option_type in self.spec.tables: self.check_object(file, f"{path}.available[{i}]", option, option_type) else: - names.append(str(option)) self.check_type(file, f"{path}.available[{i}]", option, option_type) + names.append(str(option)) + if len(names) != len(set(names)): + self.err(file, path, "names", "option names are not unique in the field") if "included" not in value: self.err(file, path, "included", "REQUIRED key 'included' is missing") for name in value.get("included", []): if name not in names: self.err(file, f"{path}.included", name, f"{name!r} names no option") - for name in (value.get("deltas") or {}): + for name in value.get("selected", []): + if name not in names: + self.err(file, f"{path}.selected", name, f"{name!r} names no option") + for name, delta in (value.get("deltas") or {}).items(): if name not in names: self.err(file, f"{path}.deltas", name, f"{name!r} names no option") + self.check_object(file, f"{path}.deltas.{name}", delta, "Delta") + if not delta: + self.err(file, f"{path}.deltas.{name}", "delta", "a Delta holds at least one key") if "max_select" in value: self.check_type(file, f"{path}.max_select", value["max_select"], "Integer") + field = path.rsplit(".", 1)[-1] + in_effect = value.get("selected", value.get("included", [])) + if "max_select" in value and len(in_effect) > value["max_select"]: + self.err(file, path, "max_select", "more options in effect than max_select") + if not in_effect and field not in EMPTY_ALLOWED: + self.err(file, path, "empty", f"{field} gives an empty set no meaning") + + def covers(self, field, outer, inner): + """Whether an entry of a settable field covers another (sections 3.2, 5.5).""" + if outer == inner: + return True + if field == "geo": + return str(inner).startswith(f"{outer}-") + if field == "device": + return outer == 1 and inner in (4, 5) + if field == "daypart": + return (set(inner.get("days", [])) <= set(outer.get("days", [])) + and outer.get("start", "") <= inner.get("start", "") + and inner.get("before", "") <= outer.get("before", "")) + return False + + def check_narrowing(self, file, path, value, item_type, allowed): + if not isinstance(value, dict) or not value.keys() <= {"include", "exclude"}: + self.err(file, path, "narrowing", "a Narrowing holds include[] and exclude[]") + return + if "include" not in value: + self.err(file, path, "include", "REQUIRED key 'include' is missing") + field = path.split(".")[-2] + include, exclude = value.get("include", []), value.get("exclude", []) + for key, within, entries in (("allowed", allowed, include), ("include", include, exclude)): + for entry in entries: + self.check_type(file, path, entry, item_type) + if not any(self.covers(field, outer, entry) for outer in within): + self.err(file, path, "covered", f"{entry!r} is not covered by {key}") + + def check_settable(self, file, path, value, item_type): + """Section 3.2: the envelope form, allowed[] with a default and the buyer's value.""" + if not isinstance(value, dict) or not value.keys() <= {"allowed", "default", "value"}: + self.err(file, path, "settable", "a Settable holds allowed[], default, and value") + return + for key in ("allowed", "default"): + if key not in value: + self.err(file, path, key, f"REQUIRED key {key!r} is missing") + allowed = value.get("allowed", []) + for i, entry in enumerate(allowed): + self.check_type(file, f"{path}.allowed[{i}]", entry, item_type) + for key in ("default", "value"): + if key in value: + self.check_narrowing(file, f"{path}.{key}", value[key], item_type, allowed) + + def check_settable_range(self, file, path, value, item_type): + """Section 3.2: the range form, the keys of Range with a default and the buyer's value.""" + if not isinstance(value, dict) or not value.keys() <= {"min", "max", "default", "value"}: + self.err(file, path, "settable", "a SettableRange holds min, max, default, and value") + return + if "min" not in value and "max" not in value: + self.err(file, path, "bound", "a SettableRange holds at least one bound") + if "default" not in value: + self.err(file, path, "default", "REQUIRED key 'default' is missing") + for key in ("min", "max", "default", "value"): + if key in value: + self.check_type(file, f"{path}.{key}", value[key], item_type) + if "min" in value and "max" in value and value["min"] > value["max"]: + self.err(file, path, "bound", "min is greater than max") + for key in ("default", "value"): + if key in value and ((("min" in value) and value[key] < value["min"]) + or (("max" in value) and value[key] > value["max"])): + self.err(file, f"{path}.{key}", "bounds", f"{key} is outside the bounds") + + def check_agreed_rate(self, file, path, item): + """Recompute agreed_rate on the committed entry (section 5.6).""" + committed = _committed_entry(item) + for entry in item.get("pricing") or []: + if entry is not committed and "agreed_rate" in entry: + self.err(file, path, "agreed_rate", "agreed_rate on an uncommitted entry") + if committed is None or "agreed_rate" not in committed: + return + decimal = lambda n: Decimal(str(n)) + cpm, factor = Decimal(0), Decimal(1) + for value in item.values(): + if not isinstance(value, dict) or "available" not in value: + continue + included = value.get("included", []) + names = [n for n in value.get("selected", included) if n not in included] + for name in names: + delta = (value.get("deltas") or {}).get(name, {}) + if committed.get("cost_method") in IMPRESSION_METHODS: + cpm += decimal(delta.get("delta_cpm", 0)) + elif set(delta) == {"delta_cpm"}: + self.err(file, path, name, "an option priced only by delta_cpm on this entry") + factor *= 1 + decimal(delta.get("delta_fraction", 0)) + rate = (decimal(committed["list_rate"]) + cpm) * factor + rate = rate.quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP) + if rate != decimal(committed["agreed_rate"]): + self.err(file, path, "agreed_rate", f"agreed_rate recomputes to {rate}") def check_fields(self, file, path, doc, fields): known = {bare(k): r for k, r in fields.items() if "[]." not in k} @@ -442,6 +575,10 @@ def check_fields(self, file, path, doc, fields): continue else: self.check_value(file, sub, doc[key], row) + if "line_item_id" in doc: + self.check_agreed_rate(file, path or "(root)", doc) + if "geo" in doc and "markets" in doc: + self.err(file, path or "(root)", "markets", "a line item declares geo or markets") def check_pricing(self, file, path, entries, fields): rows = {bare(k[len("pricing[]."):]): r for k, r in fields.items() if k.startswith("pricing[].")} diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 6067d2a..1ebb0ff 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,6 +5,30 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] +- § 1.1, § 2.1, § 2.2, § 2.6, § 3, § 3.1 to § 3.4, § 4.1, § 4.1.1, § 4.1.3, § 4.3.1, + § 4.3.2, § 5.1.1, § 5.2, § 5.2.1, § 5.4, § 5.5, § 5.6, § 5.6.1, § 5.6.3, § 5.7, § 5.9, + § 6, appendices, examples, checker — what the buyer writes, and how the rate is computed. + `selected[]` is the complete set of options in effect and replaces `included[]`; `value` + is the buyer's key on a `settable` field, beside a required `default`. An envelope's + `value` is `{ include[], exclude[] }` and only narrows `allowed[]`. `frequency_cap.count` + is the range form. The bare-value fallbacks are gone. An object option has a required + `id`, unique in the field. Options combine freely, and `measurement_source` and + `reporting` say what nothing in effect means. `agreed_rate` has one formula, sits only on + the committed entry, and rounds once. `gross_rate` becomes `list_rate`, and `floor` + becomes `min_rate`, a hint that grants nothing + ([#133](https://github.com/IABTechLab/OpenProposal/issues/133)). `delta_pct` becomes + `delta_fraction`, a fraction, so 2 percent is `0.02`. `list_rate` and `commitment_bounds` + are `requestable`, and a granted rate is the `list_rate` of the next version. `commitment` + is `mixed`. `transaction_mechanism` declares `max_select: 1`. § 3.3 states the + availability read, and § 3.4 names `BuyerWrites` and `LineItemWrites`. `geo` holds + `GeoCode` entries as a `settable` field, and the selectable form becomes `markets`. + `device` holds AdCOM codes, a `daypart` window is a `ClockWindow` that ends `before` a + minute, and `supported_formats` is removed. Breaking + ([#22](https://github.com/IABTechLab/OpenProposal/issues/22), + [#30](https://github.com/IABTechLab/OpenProposal/issues/30), + [#58](https://github.com/IABTechLab/OpenProposal/issues/58), + [#14](https://github.com/IABTechLab/OpenProposal/issues/14), + [#111](https://github.com/IABTechLab/OpenProposal/issues/111)). - § 1.2, § 2.2, § 2.3, § 2.4, § 3, § 4.1, § 4.1.1, § 4.1.2, § 4.1.3, § 4.3, § 5.1, § 5.4, § 5.6, § 5.6.1, § 5.6.3, § 5.8, § 5.9, § 6, § 7, appendices, examples, checkers — who moves each status, and when an offer goes stale. The `seller-set` gate reads the `status` diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 81e9d11..ef5ee39 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -50,6 +50,11 @@ lands in the one its `transaction_mechanism` maps to: | PMP deal | Deals API + OpenRTB 2.6 · Deal | | Open market | OpenRTB 2.6 · open auction, no deal | +A `GeoCode` (§ 5.5) converts for the +[OpenRTB 2.6 `Geo` object](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/1debba8001f40eb14597102dec3205afddf811d4/2.6.md#objectgeo): +`country` takes the ISO 3166-1 alpha-3 code of the country, and `region` takes the +part of an ISO 3166-2 code after the hyphen. + ### 1.2 Relationship to other AAMP artifacts **Identity.** Three questions about a party stay separate, for the buyer and the @@ -116,8 +121,9 @@ audiences.available[].id audiences.available[].name formats.included[] geo +markets pricing[].cost_method -pricing[].gross_rate +pricing[].list_rate pricing[].currency min_spend commitment_bounds @@ -177,7 +183,7 @@ shared type or changes its form. | `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 | +| `FractionDelta` | Number greater than -1 | Used only by `delta_fraction` (§ 3.1) | | `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 | @@ -397,12 +403,12 @@ table's keys instead, with `holds exactly one key` or `At least one is pr **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 +`Array[T]`, `Map[K, V]`, `Range[T]`, `Selectable[T]` (§ 3.1), `Settable[T]`, +`Narrowing[T]`, and `SettableRange[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 @@ -423,8 +429,8 @@ another standard and says so; that object keeps the standard's spelling and cite 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 +that isn't a noun keeps its form, such as `available[]`, `include[]`, `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 @@ -484,7 +490,7 @@ Every field carries exactly one marker. | `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. | +| `requestable` | The buyer MAY ask for a value outside the declared space. An ask changes the value only through a seller `grant` or `counter` (§ 4.3.1). A change on the seller's own initiative follows § 4.1.3. | | `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. | @@ -493,8 +499,7 @@ Every field carries exactly one marker. **Whose `status`.** The proposal's (§ 4.1) for a field defined in § 4; the line item's (§ 5.1) for a field defined in § 5, including `seller-set` sub-fields of -a `mixed` field (`pricing[]`, `buyer_requirements`) and a field treated as -`seller-set` under § 3.1 or § 3.2. +a `mixed` field. § 4.3, not the `seller-set` revision rule, governs the seller's answer to a `requestable` ask. @@ -506,9 +511,11 @@ counterparty to respond. Resolution is validation and pricing, not acceptance. A resolved `commitment` is an offer to buy (§ 5.6.1). -A field is never silently reverted. An unset `selectable` or `settable` field -takes its declared `included[]` or default, and that default MUST be explicit in -the proposal. +A field is never silently reverted. With no buyer write, what § 3.1 and § 3.2 put in +effect applies, and the seller declares it in the proposal. + +`selected[]` and `value` are keys of the field and carry no marker of their own. The +buyer MUST NOT alter the keys the seller declares beside them. ### 3.1 Selectable structure @@ -519,29 +526,109 @@ Every field marked `selectable` is a `Selectable[T]`, where `T` is the option ty | 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 | +| `included[]` | Array[String] | REQUIRED | The options in the base rate, by option id | +| `selected[]` | Array[String] | OPTIONAL | The buyer's choice, by option id | +| `deltas` | Map[String, Delta] | OPTIONAL | The price of an option outside `included[]`, keyed by option id. An option with no entry has no delta | +| `max_select` | Integer | OPTIONAL | The most options that may be in effect | + +**Option ids.** A string option's id is its value. An object option's id is its +REQUIRED `id`, unique in the field. -A field marked `selectable` that carries a bare value and no `available[]` MUST -be treated as `seller-set`. +**In effect.** `selected[]` is the complete set in effect and replaces `included[]`. +With no `selected[]`, `included[]` is in effect. `max_select` counts the whole set. An +empty set is valid only where the field gives it a meaning. Dropping an option in +`included[]` doesn't lower the rate. + +Options of different fields combine freely, apart from a rule this specification +states between them. A seller whose options don't combine publishes separate line +items. + +**`Delta`** keys. At least one key is present. § 5.6 states how they enter `agreed_rate`. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `delta_cpm` | Number | OPTIONAL | Added to the rate of a `CPM` entry, and to no other | +| `delta_fraction` | FractionDelta | OPTIONAL | Multiplies the rate of any entry by 1 plus this value. `0.20` is 20 percent more | +| `delta_flat` | Number | OPTIONAL | A charge on any entry, outside the rate | + +An option priced only by `delta_cpm` MUST NOT resolve against any other entry. ### 3.2 Settable bounds -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` declares its bounds in one of two forms, and the buyer +writes one key, `value`. `frequency_cap` holds its form in `count`. The keys of +`commitment` are the one exception: `commitment_bounds` holds their bounds (§ 5.6, +§ 5.6.1). + +**`Settable`** is the envelope form. `value` only narrows `allowed[]`. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `allowed[]` | Array[T] | REQUIRED | The entries the buyer narrows within | +| `default` | Narrowing[T] | REQUIRED | In effect when `value` is absent | +| `value` | Narrowing[T] | OPTIONAL | The buyer's value | + +**`Narrowing`** keys. Exclusion wins. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `include[]` | Array[T] | REQUIRED | Each entry MUST be covered by an `allowed[]` entry | +| `exclude[]` | Array[T] | OPTIONAL | Each entry MUST be covered by an `include[]` entry | + +An entry covers itself and what the field says it contains. Nothing else is assumed. + +**`SettableRange`** holds the keys of `Range[T]` (§ 2.2) and these. It is the range +form. -A field marked `settable` that carries a bare value and no bounds MUST be -treated as `seller-set`. +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `default` | T | REQUIRED | In effect when `value` is absent, between the bounds | +| `value` | T | OPTIONAL | The buyer's value, between the bounds inclusive | ### 3.3 Re-computation -Because audiences, properties, formats and, where it is selectable, `geo` are -`selectable`, and buyer `exclusions` (§ 5.5) narrow delivery, `addressable_scale` and `availability` -MUST be recomputed against the buyer's actual selection and exclusions. A -single declared figure is correct only for the default composition. +Because audiences, properties, formats, and `markets` are `selectable`, and a +`settable` targeting `value` and buyer `exclusions` (§ 5.5) narrow delivery, +`addressable_scale` and `availability` MUST be recomputed against the buyer's +actual selection, values, and exclusions. A single declared figure is correct only +for the default composition. + +A buyer reads these figures by sending the seller an `AvailabilityRead`. The read changes +no `status`, writes no history, reserves nothing, and doesn't limit a later decline +(§ 5.6.1). + +**`AvailabilityRead`** holds the keys of `BuyerWrites` (§ 3.4) and these: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `offer` | VersionRef | REQUIRED | The line item and the `version` whose options the figures are computed against. `line_item_id` is REQUIRED here | +| `flight` | Interval | REQUIRED | The flight, as `commitment.flight` (§ 5.6.1) | + +### 3.4 Buyer writes + +**`BuyerWrites`** holds what a buyer writes on one line item. It is a strict path +subset of the line item: a listed path has the type it has there, and no other path +appears. Every path is OPTIONAL. A buyer artifact delivered after agreement +(§ 4.1.1), such as `assets[]`, is not a write. + +`BuyerWrites` lists these paths, where `` is a field marked `selectable`, +and `` is a field marked `settable` other than `frequency_cap`: + +```text +.selected[] +.value +frequency_cap.count.value +exclusions +buyer_requirements.seats +buyer_requirements.seat_owner +``` + +**`LineItemWrites`** holds the keys of `BuyerWrites` and this one. It is the buyer's writes +on one line item. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `commitment` | Commitment | REQUIRED | The line item's `commitment` (§ 5.6.1) | --- @@ -593,10 +680,12 @@ items. | `requested_value` | RequestedValue | REQUIRED when the entry's `action` is `requested` | On a `requested` entry, the value the buyer asked for. On a `declined` entry, or a buyer's `withdrawn` entry, the value of the `request` it ended. Absent on every other entry | **`RequestedValue`** holds exactly one key: the `requestable` field `pointer` names, in the -form its row states. +form its row states. `pointer` says which `pricing[]` entry. | Key | Type | Required | Meaning | | :--- | :--- | :--- | :--- | +| `list_rate` | Number | OPTIONAL | A `pricing[].list_rate` | +| `commitment_bounds` | Object | OPTIONAL | The line item's `commitment_bounds` | | `materials_due` | MaterialsDue | OPTIONAL | The line item's `materials_due` | | `committed_metrics[]` | Array[CommittedMetric] | OPTIONAL | The line item's `committed_metrics[]` | @@ -638,7 +727,7 @@ $.line_items[*].addressable_scale $.line_items[*].assets $.line_items[*].buyer_supplied.match_rate_expected $.line_items[*].pricing[*].seller_rate -$.line_items[*].pricing[*].floor +$.line_items[*].pricing[*].min_rate $.line_items[*].hold_status.*, except hold_available, hold_duration and hold_scope $.line_items[*].commitment.version $.line_items[*].commitment.idempotency_key @@ -655,17 +744,18 @@ informative figure, or an identifier minted downstream of agreement (`availability`, `addressable_scale`, `match_rate_expected`, `derived_units`, `deal_id_issuance`, `external_references[]`, `correlation_id`); or a value that is not a term of the agreement: the seller's own figures (`seller_rate`, -`floor`; the transacted rate is `agreed_rate`) and buyer artifacts delivered +`min_rate`; the transacted rate is `agreed_rate`) and buyer artifacts delivered after agreement (`assets[]`). The requirement on creative (`formats`, `specs`, `creative_policy`, `materials_due`) is a term. Every other value is inside the record: `seller`, `buyer`, and `issued_from`; every `seller-set` field, including prose (`description`, `specifications`, `content_detail`, -`creative_policy`, `specs`); every option space (`available[]`, `included[]`, `deltas`, -`max_select`, `allowed[]`, `min` / `max`); every value the buyer writes under -§ 3 (selections, `settable` values, `commitment`, data under `supplied` fields -other than `assets[]`, any resolved `requestable` ask); `pricing[].agreed_rate`; -and `hold_available`, `hold_duration`, `hold_scope`. +`creative_policy`, `specs`); the seller's value of a `requestable` field, such as +`pricing[].list_rate`, including one a `grant` or `counter` put in force; every option +space (`available[]`, `included[]`, `deltas`, `max_select`, `allowed[]`, `min` / `max`, +`default`); every value the buyer writes under § 3 (`selected[]`, `value`, +`commitment`, data under `supplied` fields other than `assets[]`); +`pricing[].agreed_rate`; and `hold_available`, `hold_duration`, `hold_scope`. **Digest input.** The record is computed over the composed record as the parties exchange it: the Full Representation of `version` as the seller published it to @@ -776,21 +866,20 @@ is a convenience, not evidence. #### 4.1.3 Offered terms and versions **Offered terms.** The offered terms of a version are every value the seller -writes: `immutable` and `seller-set` fields; the seller-declared structure of -`selectable`, `settable` and `requestable` fields (`available[]`, `included[]`, -`deltas`, `max_select`, `allowed[]`, `min`, `max`, declared defaults and -ceilings); and the set of `line_item_id`s the version carries. The following -are not offered terms and MAY change without a new version: `status` (proposal +writes: `immutable` and `seller-set` fields; the seller's value of a `requestable` +field, such as `pricing[].list_rate`; the seller-declared structure of `selectable` +and `settable` fields (`available[]`, `included[]`, `deltas`, `max_select`, +`allowed[]`, `min`, `max`, `default`); and the set of `line_item_id`s the version +carries. The following are not offered terms and MAY change without a new version: `status` (proposal and line item), `assent[]`, `negotiation_history[]`, `availability`, `addressable_scale`, the hold's status (every `hold_status` key except `hold_available`, `hold_duration` and `hold_scope`, which offer the hold), `deal_id_issuance`, `external_references[]`, `correlation_id`, `assets[].approval_status`; `pricing[].seller_rate`, which is what the publisher -receives and not a term offered to the buyer; every value the buyer writes -(`supplied` fields, `commitment`, selections and values on `selectable` and -`settable` fields, and a `requestable` ask); and every `derived` value computed -from a buyer's writes (`pricing[].agreed_rate`, `commitment.derived_units`). A -buyer composes against a named `version` and never increments it. +receives and not a term offered to the buyer; every value the buyer writes (a +`BuyerWrites` value, `commitment`, any other `supplied` field, and a `requestable` +ask); and every `derived` value computed from a buyer's writes (`pricing[].agreed_rate`, +`commitment.derived_units`). A buyer composes against a named `version` and never increments it. **Freezing.** A seller MAY edit a proposal in place only while its current version is not yet frozen (§ 4.1). Two representations of the same @@ -876,6 +965,7 @@ it ends is a new ask. Holds change neither `status` nor `negotiation_history[]`. | Interaction | Actor | Effect; history `action` | | :--- | :--- | :--- | | `publish` | seller | version 1 of a new or `draft` proposal, or the next `version` for a change to offered terms (§ 4.1.3); `published`. Moving a `draft` line item in a frozen version: neither (§ 4.3.2) | +| availability read | buyer | an `AvailabilityRead` (§ 3.3), answered with the figures § 3.3 names, with no change of state; no entry | | `request` | buyer | asks a `requestable` value on one line item against a named `version`, stated as a `HistoryPath` (§ 4.1) with `requested_value`; `requested` | | `grant` | seller | of a `request`: new `version` with the requested value in force as an offered term; `granted` | | `counter` | seller | of a `request`: new `version` with another value; `countered` | @@ -918,8 +1008,9 @@ exhaustive, so a status with none leading out of it is terminal. A *firm entry* is a `pricing[]` entry with a `price_valid_until`. It holds until that date or `offer_window.end`, whichever comes first. While it holds, a seller revision MUST NOT change that entry, and MUST NOT end it sooner by moving -`offer_window.end`. A revision that narrows who may commit (§ 5.8) does not apply, -while the entry holds, to a buyer that was eligible when the entry was first offered. +`offer_window.end`. A `grant` or `counter` of a `request` on that entry MAY lower its +`list_rate`. A revision that narrows who may commit (§ 5.8) does not apply, while the +entry holds, to a buyer that was eligible when the entry was first offered. The seller of an issued proposal MUST answer a resolved `commit` under it that arrives while the entry holds, on or after `offer_window.start`, and passes the checks of § 5.6.1 and any authority check the seller applies (§ 5.8): accept it, or decline it @@ -996,7 +1087,7 @@ served. | :--- | :--- | | `dooh` | Distribution channel `Dooh` | | `audio` | Audio ad (`AudioPlacement`) | -| `ctv` | Device type Connected TV (3), Connected Device (6) or Set Top Box (7) | +| `ctv` | A TV device type | | `native` | Native ad (`DisplayPlacement.nativefmt`), including native ads with video assets | | `video` | Video ad (`VideoPlacement`) | | `display` | Any other display ad (`DisplayPlacement`) | @@ -1006,7 +1097,9 @@ 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. +name followed by its code. The *TV device types* are Connected TV (3), Connected +Device (6), and Set Top Box (7). The *mobile device types* are Mobile/Tablet - +General (1), Phone (4), and Tablet (5). - The seller MUST serve a line item only on impressions of its `channel`. Placement type, such as interstitial or rewarded @@ -1019,7 +1112,7 @@ name followed by its code. | Field | Type | Required | Mutability | Description | | :--- | :--- | :--- | :--- | :--- | -| `properties` | Selectable[PropertyOption] | OPTIONAL | selectable | Options of `{ id, name, domains[], supported_formats[] }` | +| `properties` | Selectable[PropertyOption] | OPTIONAL | selectable | Options of `{ id, name, domains[] }` | | `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 | @@ -1033,9 +1126,9 @@ 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) | +| `web_mobile` | `Site` | The mobile device types | +| `app_mobile` | `App` | The mobile device types | +| `ctv` | `Site` or `App` | The TV device types | | `dooh` | `Dooh` | Any | Each `environments.available[]` value MUST match at least one impression to @@ -1094,7 +1187,7 @@ which 5.1.1 gives the line item's `channel`. | Key | Type | Required | Meaning | | :--- | :--- | :--- | :--- | -| `id` | String | REQUIRED | Option id | +| `id` | String | REQUIRED | Option id (§ 3.1) | | `custom_unit` | String | OPTIONAL | The custom unit built | | `build_owner` | Side | OPTIONAL | Which side builds it | | `lead_time` | Duration | OPTIONAL | Time the build needs | @@ -1141,18 +1234,18 @@ of the applicable execution standard. ### 5.5 Targeting envelope -Each field except `time_zone`, `exclusions_accepted` and `exclusions` declares -bounds, not a value (§ 3.2). A bare value means the field is `seller-set`. For -`exclusions_accepted` and `exclusions`, the seller declares what it can -enforce, and the buyer supplies its lists. `geo` MAY instead declare the -selectable structure of § 3.1, so a line item can be priced by market. +`geo`, `device`, `daypart`, and `frequency_cap` declare bounds, not a value (§ 3.2), and +one that is absent declares none. `markets` declares options (§ 3.1). For +`exclusions_accepted` and `exclusions`, the seller declares what it can enforce, and the +buyer supplies its lists. | Field | Type | Required | Mutability | Description | | :--- | :--- | :--- | :--- | :--- | -| `geo` | Settable[String] | OPTIONAL | settable | `allowed[]` of geo codes the buyer may select within. A `geo` MAY instead declare `available[]` with `included[]`, `deltas` and `max_select` (§ 3.1), for a line item priced by market. See below | -| `device` | Settable[String] | OPTIONAL | settable | `allowed[]` of device categories | -| `daypart` | Settable[Object] | OPTIONAL | settable | `allowed[]` of `{ days[], start, end }`: `days[]` from `monday` \| `tuesday` \| `wednesday` \| `thursday` \| `friday` \| `saturday` \| `sunday`, HH:MM civil time, `end` exclusive, up to `24:00` | -| `time_zone` | String | REQUIRED when `daypart` or `frequency_cap` is present | seller-set | IANA time zone name, or `viewer_local` (where the ad renders). The clock for `daypart` and `frequency_cap.period` | +| `geo` | Settable[GeoCode] | OPTIONAL | settable | Where the line item runs. `allowed[]` of `GeoCode` (below). A line item MUST NOT declare both `geo` and `markets` | +| `markets` | Selectable[GeoCode] | OPTIONAL | selectable | For a line item priced by market. See `GeoCode` below | +| `device` | Settable[Integer] | OPTIONAL | settable | `allowed[]` of [AdCOM 1.0 List: Device Types](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#list_devicetypes) codes, the values of OpenRTB 2.6 `device.devicetype`. It narrows `environments` (§ 5.2.1). Mobile/Tablet - General (1) covers Phone (4) and Tablet (5) | +| `daypart` | Settable[ClockWindow] | OPTIONAL | settable | `allowed[]` of `ClockWindow` (below). A window covers any window inside it | +| `time_zone` | ZoneName | REQUIRED when `daypart` or `frequency_cap` is present | seller-set | The clock for `daypart` and a calendar `frequency_cap.period` | | `frequency_cap` | FrequencyCap | OPTIONAL | settable | See below | | `exclusions_accepted` | Object | OPTIONAL | seller-set | What the seller can enforce: `{ types[], max_entries, enforcement, verification[] }`. See below | | `exclusions` | Object | OPTIONAL | supplied | The buyer's block lists: `{ domain[], app_bundle[], iab_content_category[], keyword[], list_ref }`. See below | @@ -1190,66 +1283,87 @@ exclusions: - `list_ref` MUST be hash-pinned, and resolved into the stored record at `agreed`. -**`FrequencyCap`** keys: +**`GeoCode`** is a String: an ISO 3166-1 alpha-2 country code, an ISO 3166-2 +subdivision code, or an extension `VocabularyTerm` for any other area, such as a +metro list. The country of an ISO 3166-2 code is the part before the hyphen, and a +country covers every subdivision code it prefixes. An extension term covers only +itself. § 1.1 states the conversion to OpenRTB 2.6. -| Key | Type | Required | Meaning | -| :--- | :--- | :--- | :--- | -| `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 | +```yaml +geo: + allowed: ["US"] + default: { include: ["US"] } + value: { include: ["US"], exclude: ["US-TX"] } # the buyer's write +``` -**Market-priced lines.** A seller whose rate differs by market MAY declare `geo` -with the selectable structure of § 3.1: +**Market-priced line items.** A seller whose rate differs by market declares `markets`: ```yaml -geo: - available: ["SGP", "THA", "IDN", "MYS"] - included: ["SGP", "THA"] - deltas: { "IDN": { delta_cpm: 18.00 }, "MYS": { delta_cpm: 21.00 } } +markets: + available: ["SG", "TH", "ID", "MY"] + included: ["SG", "TH"] + deltas: { "ID": { delta_cpm: 18.00 }, "MY": { delta_cpm: 21.00 } } max_select: 4 + selected: ["SG", "ID"] # the buyer's write: runs in these two, and pays the ID delta ``` -- A `geo` that declares `allowed[]` and no `available[]` MUST be treated as - `settable` (§ 3.2). A `geo` MUST NOT declare both. -- `deltas` add to `agreed_rate` as for any selectable field (§ 3.1). +**`ClockWindow`** is a repeating window of civil time, deliberately not an `Interval`. +A window that crosses midnight is two windows. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `days[]` | Array[DayOfWeek] | REQUIRED | The days the window repeats on | +| `start` | ClockTime | REQUIRED | The first minute. Not `24:00` | +| `before` | ClockTime | REQUIRED | The first minute after the window, later than `start` | -**Window shape.** A `daypart` window is `{ days[], start, end }`, with `days[]` -from Monday to Sunday, `start` and `end` as HH:MM, and `end` exclusive, -later than `start`, and allowed to be `24:00`. A window that crosses midnight -is two windows: +**`ClockTime`** is a String: `HH:MM` on a 24-hour clock, two digits each, from `00:00` +to `23:59`, or `24:00`, the end of the day. + +**`DayOfWeek`** is `monday` \| `tuesday` \| `wednesday` \| `thursday` \| `friday` \| +`saturday` \| `sunday`. ```yaml # Late fringe, 23:00 to 01:00, Monday night into Tuesday daypart: allowed: - - { days: [monday], start: "23:00", end: "24:00" } - - { days: [tuesday], start: "00:00", end: "01:00" } + - { days: [monday], start: "23:00", before: "24:00" } + - { days: [tuesday], start: "00:00", before: "01:00" } + default: + include: + - { days: [monday], start: "23:00", before: "24:00" } + - { days: [tuesday], start: "00:00", before: "01:00" } time_zone: "America/New_York" ``` -**Clock.** `time_zone` names a zone in the -[IANA time zone database](https://www.iana.org/time-zones), such as -America/New_York or UTC. Windows follow daylight-saving rules: a skipped +**Clock.** Windows follow daylight-saving rules: a skipped hour matches no instant; a repeated hour matches both. Like the calendar Day of [OpenDirect 2.1 `Line.frequencyinterval`](https://github.com/InteractiveAdvertisingBureau/OpenDirect/blob/82bc294314aba017fc639ba634aecc53adfd675f/OpenDirect.v2.1.final.md#object_line), -`frequency_cap.period` is a calendar period in `time_zone`; weeks start on +a `frequency_cap.period` other than `flight` is a calendar period in `time_zone`; weeks start on Monday. +**`FrequencyCap`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `count` | SettableRange[Integer] | REQUIRED | The cap. The buyer's `value` is the count, and `period` and `per` stay the seller's | +| `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 | 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[].list_rate` | Number | REQUIRED | requestable | The rate before options. A buyer MAY request another value, and a granted value is the `list_rate` of the next `version` | +| `pricing[].agreed_rate` | Number | REQUIRED when this is the committed entry | derived | The transacted rate (below). Absent on every other entry | | `pricing[].seller_rate` | Number | OPTIONAL | seller-set | What the publisher receives | | `pricing[].price_valid_until` | Instant | OPTIONAL | seller-set | How long the entry holds (§ 4.3.2). Distinct from the proposal's `offer_window.end` | | `pricing[].currency` | String | REQUIRED | seller-set | ISO-4217 | -| `pricing[].min_rate` | Number | OPTIONAL | seller-set | A hint: the lowest rate the seller says it will consider. It grants nothing. Not a term, not the downstream auction floor, and never sent downstream | +| `pricing[].min_rate` | Number | OPTIONAL | seller-set | A hint: the lowest `list_rate` the seller says it will consider. It grants nothing. Not a term, not the downstream auction floor, and never sent downstream | | `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 | +| `commitment_bounds` | Object | OPTIONAL | requestable | The envelope inside which a buyer may commit, and a buyer MAY request another: `{ min_impressions, max_impressions, min_budget, max_budget }` | +| `commitment` | Commitment | OPTIONAL | mixed | **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 | @@ -1257,11 +1371,28 @@ Monday. | `creative_policy` | Object | OPTIONAL | seller-set | Content and category restrictions on creative | | `requires` | Requires | OPTIONAL | seller-set | Conditions that must hold in the same `commit` for this line item to be committable. See 5.6.3 | +The *committed entry* is the `pricing[]` entry the commitment is priced against. + +**`agreed_rate`** is computed on the committed entry from the options in effect +(§ 3.1), in exact decimal, and rounded once, half up, to 6 decimal places: + +```text +agreed_rate = (list_rate + sum of delta_cpm) × product of (1 + delta_fraction) +``` + +A delta counts when its option is in effect and not in `included[]`, on any field of the +line item marked `selectable`. A `delta_cpm` enters only a `CPM` entry (§ 3.1), and +the option's other deltas still count. So `list_rate` 24.00 with one `delta_cpm` of +3.00 and one `delta_fraction` of 0.02 is 27.54. `delta_flat` stays outside it. + #### 5.6.1 commitment -Absent on a shared proposal (§ 4.3). Populated during composition. A -`commitment` resolves under § 3 when the buyer is eligible (§ 5.8), and the -commitment fits `commitment_bounds` and `min_spend`, is not stale (§ 4.1.3), +Absent on a shared proposal (§ 4.3). The buyer writes it on its composed record +(§ 4.1.1), and a `commit` carries it to the seller. The buyer writes its `settable` keys +itself, and they have no `default` (§ 3.2). A +`commitment` resolves under § 3 when the buyer is eligible (§ 5.8), every `selected[]` +and `value` on the line item is within what the seller declared (§ 3.1, § 3.2), and +the commitment fits `commitment_bounds` and `min_spend`, is not stale (§ 4.1.3), and meets `requires` (§ 5.6.3). A resolved `commitment` is an offer to buy. It binds only when the seller accepts it; `assent[]` (§ 4.1) records that acceptance for the binding `version`. A seller @@ -1281,17 +1412,17 @@ is downstream (§ 1.1). Frozen when the line item's `status` becomes `agreed`. | `basis` | Enum | REQUIRED | `spend` \| `units` | | `amount` | Number | REQUIRED | The figure committed | -**`Commitment`** holds the keys of `CommitmentAmount` and these: +**`Commitment`** holds the keys of `CommitmentAmount`, each `settable`, and these: -| Key | Type | Required | Meaning | -| :--- | :--- | :--- | :--- | -| `proposal_id` | String | REQUIRED | The proposal the commitment is made against | -| `version` | Integer | REQUIRED | The proposal `version` the commitment was priced against | -| `currency` | String | REQUIRED when `basis` is `spend` | ISO-4217 | -| `flight` | Interval | REQUIRED | The buyer's campaign. **Distinct from `offer_window`** | -| `advertisers[]` | Array[Party] | OPTIONAL | Whom the spend is for | -| `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 | +| Key | Type | Required | Meaning | Mutability | +| :--- | :--- | :--- | :--- | :--- | +| `proposal_id` | String | REQUIRED | The proposal the commitment is made against | settable | +| `version` | Integer | REQUIRED | The proposal `version` the commitment was priced against | settable | +| `currency` | String | REQUIRED when `basis` is `spend` | ISO-4217 | settable | +| `flight` | Interval | REQUIRED | The buyer's campaign. **Distinct from `offer_window`** | settable | +| `advertisers[]` | Array[Party] | OPTIONAL | Whom the spend is for | settable | +| `derived_units` | Integer | OPTIONAL | Computed from `amount`, `agreed_rate` and `cost_method`. Informative, not binding | derived | +| `idempotency_key` | String | REQUIRED | Buyer-generated, unique per commitment attempt | settable | An `idempotency_key` is opaque and scoped to one buyer and seller pair. The seller MUST NOT interpret it. @@ -1350,7 +1481,7 @@ requires: ``` 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 +a discounted `list_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. @@ -1370,14 +1501,15 @@ self-contained. | 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 | +| `measurement_source` | Selectable[MeasurementOption] | OPTIONAL | selectable | See `MeasurementOption` below. Lets a buyer transact in its own currency. Nothing in effect: no provider | | `shortfall_remedy` | Selectable[VocabularyTerm] | OPTIONAL | selectable | Core terms: `makegood` \| `credit` \| `none`, plus `remedy_terms` | -| `reporting` | Selectable[ReportingOption] | OPTIONAL | selectable | See `ReportingOption` below | +| `reporting` | Selectable[ReportingOption] | OPTIONAL | selectable | See `ReportingOption` below. Nothing in effect: no report is promised | **`MeasurementOption`** keys: | Key | Type | Required | Meaning | | :--- | :--- | :--- | :--- | +| `id` | String | REQUIRED | Option id (§ 3.1) | | `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 | @@ -1386,6 +1518,7 @@ self-contained. | Key | Type | Required | Meaning | | :--- | :--- | :--- | :--- | +| `id` | String | REQUIRED | Option id (§ 3.1) | | `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` | @@ -1465,7 +1598,7 @@ and signing profile (provisional name). They are not expressed through | 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 | +| `transaction_mechanism` | Selectable[TransactionMechanism] | REQUIRED | selectable | See `TransactionMechanism` below. A seller MAY offer the same inventory through more than one. `max_select: 1` | | `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 on acceptance of the commitment (§ 5.6.1) | | `buyer_requirements` | BuyerRequirements | OPTIONAL | mixed | See below | @@ -1545,7 +1678,8 @@ 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). +Shows a line item priced by market. See +[`examples/line-item-audio.yaml`](../examples/line-item-audio.yaml). ### 6.5 Bonus line item @@ -1562,9 +1696,11 @@ display line item is `registry_gated`, so it is not listed. See ### 6.12 Issued proposal, partly agreed A proposal issued to one buyer from the shared proposal of § 6.1 (§ 4.3). One line -item is agreed at version 1, with a commitment. The other stays `published` at -version 2, with a hold, so the proposal stays `published` (§ 4.3.3). The `assent[]` -entries name version 1, and the digest is over that line item alone (§ 4.1.2). See +item is agreed at version 1, with a commitment. Its buyer writes `selected[]` and +`value`, and `agreed_rate` follows from the options in effect (§ 5.6). The other stays +`published` at version 2, with a hold, so the proposal stays `published` (§ 4.3.3). The +`assent[]` entries name version 1, and the digest is over that line item alone +(§ 4.1.2). See [`examples/proposal-issued.yaml`](../examples/proposal-issued.yaml). --- @@ -1694,23 +1830,25 @@ body is normative.** | `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 | +| `geo` | 5.5 Targeting envelope | Settable[GeoCode] | OPTIONAL | settable | +| `markets` | 5.5 Targeting envelope | Selectable[GeoCode] | OPTIONAL | selectable | +| `device` | 5.5 Targeting envelope | Settable[Integer] | OPTIONAL | settable | +| `daypart` | 5.5 Targeting envelope | Settable[ClockWindow] | OPTIONAL | settable | +| `time_zone` | 5.5 Targeting envelope | ZoneName | REQUIRED when `daypart` or `frequency_cap` is present | seller-set | | `frequency_cap` | 5.5 Targeting envelope | FrequencyCap | OPTIONAL | settable | | `exclusions_accepted` | 5.5 Targeting envelope | Object | OPTIONAL | seller-set | | `exclusions` | 5.5 Targeting envelope | Object | OPTIONAL | supplied | | `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[].list_rate` | 5.6 Commercial terms | Number | REQUIRED | requestable | +| `pricing[].agreed_rate` | 5.6 Commercial terms | Number | REQUIRED when this is the committed entry | 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 | +| `pricing[].min_rate` | 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 | +| `commitment_bounds` | 5.6 Commercial terms | Object | OPTIONAL | requestable | +| `commitment` | 5.6 Commercial terms | Commitment | OPTIONAL | mixed | | `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 | @@ -1746,24 +1884,26 @@ body is normative.** | Marker | Count | Meaning | Fields | | :--- | :--- | :--- | :--- | | immutable | 9 | Set once at creation. Neither party may change it. | `proposal_id`, `seller`, `buyer`, `type`, `proposal_template_id`, `issued_from`, `brief_id`, `line_item_id`, `channel` | -| seller-set | 39 | The seller sets it and MAY revise it while the `status` of the object that carries it is `draft`, `published` or `under_review`. § 4.3.2 states what a revision does to a line item `under_review`. The buyer cannot change it directly. | `spec_version`, `offer_window`, `line_items[]`, `linked_execution`, `ext` (proposal), `description`, `specifications`, `best_for[]`, `not_suitable_for[]`, `seasonality_notes`, `ext` (line item), `content_detail`, `specs`, `third_party_tags`, `creative_delivery`, `approval`, `exclusions_accepted`, `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_buyers[]`, `eligible_seats[]`, `terms_acceptance_required`, `terms_ref`, `buying_route`, `serving_mode`, `serving_platform`, `agreement`, `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` | +| seller-set | 38 | The seller sets it and MAY revise it while the `status` of the object that carries it is `draft`, `published` or `under_review`. § 4.3.2 states what a revision does to a line item `under_review`. The buyer cannot change it directly. | `spec_version`, `offer_window`, `line_items[]`, `linked_execution`, `ext` (proposal), `description`, `specifications`, `best_for[]`, `not_suitable_for[]`, `seasonality_notes`, `ext` (line item), `content_detail`, `specs`, `third_party_tags`, `creative_delivery`, `approval`, `time_zone`, `exclusions_accepted`, `pricing[].cost_method`, `pricing[].seller_rate`, `pricing[].price_valid_until`, `pricing[].currency`, `pricing[].min_rate`, `min_spend`, `creative_policy`, `requires`, `visibility`, `required_credentials[]`, `eligible_buyers[]`, `eligible_seats[]`, `terms_acceptance_required`, `terms_ref`, `buying_route`, `serving_mode`, `serving_platform`, `agreement`, `billing`, `supported_order_systems[]` | +| selectable | 13 | 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`, `markets`, `cancellation_policy`, `exclusivity`, `measurement_source`, `shortfall_remedy`, `reporting`, `transaction_mechanism` | +| settable | 4 | The buyer supplies a value within seller-declared bounds. Resolves without a round trip. | `geo`, `device`, `daypart`, `frequency_cap` | | supplied | 1 | The buyer provides data or an artifact. The seller does not pre-populate it. | `exclusions` | -| requestable | 2 | The buyer MAY ask for a value outside the declared space. Requires explicit seller assent. | `materials_due`, `committed_metrics[]` | +| requestable | 4 | The buyer MAY ask for a value outside the declared space. An ask changes the value only through a seller `grant` or `counter` (§ 4.3.1). A change on the seller's own initiative follows § 4.1.3. | `materials_due`, `pricing[].list_rate`, `commitment_bounds`, `committed_metrics[]` | | derived | 6 | Computed by a rule this specification states. Neither party sets it directly. | `version`, `status` (proposal), `record_digest`, `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 | 3 | Written by the party that acted. Append-only: an entry is never edited or removed. Not a term. | `assent[]`, `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` | +| mixed | 6 | 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[]`, `commitment`, `hold_status`, `buyer_requirements` | ### What the distribution says -Of 85 fields, **20 are touched by the buyer** — 12 `selectable`, 5 `settable`, 1 -`supplied` and 2 `requestable`. The remaining 65 are `seller-set`, `immutable`, -`derived`, `lifecycle`, `recorded`, `asserted` or `mixed`. +Of 87 fields, **22 are touched by the buyer** — 13 `selectable`, 4 `settable`, 1 +`supplied` and 4 `requestable`. The remaining 65 are `seller-set`, `immutable`, +`derived`, `lifecycle`, `recorded`, `asserted` or `mixed`. The buyer also writes some of +those: its own entries in `assent[]`, and its keys of `commitment`, `assets[]`, +`buyer_supplied`, and `buyer_requirements`. -Only the 2 `requestable` fields require a round trip to resolve (§ 5.6.1). +Only the 4 `requestable` fields require a round trip to resolve (§ 5.6.1). Everything else the buyer touches — 18 fields — resolves against bounds the seller declared up front. That ratio is the design claim: composition is constraint satisfaction, and negotiation is the exception. From e3eb0ad994126c23ab40ad5e0d58eb7e0ff55d51 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Wed, 4 Nov 2026 01:43:22 +0100 Subject: [PATCH 34/47] Define what is signed and how a seller checks it Keep record_digest only on assent entries, and state the exact digest bytes. Cut the record to terms: min_rate, the advisory fields, segments, and the suppression list leave it, and seller_rate and deal_id_issuance go. Give AssentEntry a key table with four roles, so only assent[] decides agreed, and move idempotency_key onto the entry. Name the Commit and its CommitResult. The seller recomputes the digest over the line items as they stand, and rejects in a stated order. A commitment entry stops being live when its commit goes stale or void. Carry entries in messages signed under the OpenProposal profile of Web Bot Auth, as optional evidence. Add HttpsUrl, and replace terms_ref with terms_refs[] of DocumentRef. Add the agreed record example, and teach the checker the assent rules. --- examples/line-item-display.yaml | 7 +- examples/proposal-agreed.yaml | 108 +++++++ examples/proposal-issued.yaml | 12 +- examples/proposal.yaml | 3 +- scripts/check_appendix.py | 3 +- scripts/check_examples.py | 91 +++++- spec/CHANGELOG.md | 34 +++ spec/openproposal-3.0.md | 504 +++++++++++++++++++------------- 8 files changed, 532 insertions(+), 230 deletions(-) create mode 100644 examples/proposal-agreed.yaml diff --git a/examples/line-item-display.yaml b/examples/line-item-display.yaml index e203f42..1fa72ca 100644 --- a/examples/line-item-display.yaml +++ b/examples/line-item-display.yaml @@ -63,7 +63,6 @@ exclusions_accepted: # what this seller can enforce (§ 5.5) pricing: - cost_method: CPM list_rate: 24.00 - seller_rate: 20.40 min_rate: 21.00 price_valid_until: "2026-11-15T23:59:59Z" currency: USD @@ -88,9 +87,9 @@ reporting: visibility: registry_gated required_credentials: [{ credential_type: iab_registry, issuer: "registry.iabtechlab.ai" }] -terms_acceptance_required: true -terms_ref: { url: "https://examplepub.example/legal/io-terms-2026.pdf", - sha256: "", version: "2026.1" } +terms_refs: + - { url: "https://examplepub.example/legal/io-terms-2026.pdf", + digest: "sha256:83e44f6fa723c35fef1b3528b16cc13a8a19ee34a32b658f6d3cc243ce12592e", version: "2026.1" } buying_route: PUBLISHER_AD_SERVER transaction_mechanism: { available: ["programmatic_guaranteed", "preferred_deal"], diff --git a/examples/proposal-agreed.yaml b/examples/proposal-agreed.yaml new file mode 100644 index 0000000..d9781fb --- /dev/null +++ b/examples/proposal-agreed.yaml @@ -0,0 +1,108 @@ +# OpenProposal 3.0 — Example: Agreed record (spec § 6.8) +# Illustrative only. Seller names and rates are fictional. Both line items are +# agreed at version 2, after the seller countered a later materials_due. +# record_digest recomputes from this document under § 4.1.1, and it covers the +# digest of each document in terms_refs[], so the entries bind those exact bytes. +proposal_id: "examplepub-agencya-custom-q4-2026" +version: 2 +spec_version: "3.0" +seller: { domain: "examplepub.example", legal_name: "Example Publisher US", + registry_id: "reg:examplepub-digital" } +buyer: { domain: "agency-a.example", legal_name: "Agency A LLC" } +type: custom +brief_id: "agency-a-brief-q4-2026" +status: agreed # derived: no line item remains offered (§ 4.3.3) +offer_window: { start: "2026-09-14T00:00:00Z", end: "2026-12-31T23:59:59Z" } + +description: > + Display and CTV package for Agency A, built from its brief. + +assent: + - { role: commitment, version: 2, + record_digest: "sha256:f35e6a49f1e10237056bee55a526a6d4e3ecc7d5006dc3687e7eef45d0339067", + line_item_ids: ["business-daily-display", "weather-app-ctv"], + at: "2026-09-22T10:14:03Z", + idempotency_key: "agency-a-camp1-q4-2026-5d10e2c7" } + - { role: acceptance, version: 2, + record_digest: "sha256:f35e6a49f1e10237056bee55a526a6d4e3ecc7d5006dc3687e7eef45d0339067", + line_item_ids: ["business-daily-display", "weather-app-ctv"], + at: "2026-09-22T10:31:47Z" } + +negotiation_history: + - { version: 1, actor: seller, action: published, paths: [], + at: "2026-09-14T09:00:00Z" } + - { version: 1, actor: buyer, action: requested, + paths: [{ line_item_id: "business-daily-display", pointer: "/materials_due", + requested_value: { materials_due: { at: "2026-10-01T23:59:59Z" } } }], + at: "2026-09-14T09:02:11Z" } + - { version: 2, actor: seller, action: countered, # another value, version 2 + paths: [{ line_item_id: "business-daily-display", pointer: "/materials_due" }], + at: "2026-09-14T09:40:05Z" } + +line_items: + - line_item_id: "business-daily-display" + status: agreed + channel: display + properties: + available: [{ id: "business-daily", domains: ["business-daily.example"] }] + included: ["business-daily"] + formats: + available: ["display/300x250"] + included: ["display/300x250"] + creative_delivery: { method: buyer_hosted, protocol: javascript_tag, secure_required: true } + materials_due: { at: "2026-09-28T23:59:59Z" } # version 1 had "2026-09-24T23:59:59Z" + pricing: + - { cost_method: CPM, list_rate: 24.00, agreed_rate: 24.00, min_rate: 21.00, + price_valid_until: "2026-11-15T23:59:59Z", currency: USD } + commitment_bounds: { min_budget: 10000.00, max_budget: 500000.00 } + commitment: + basis: spend + amount: 80000.00 + currency: USD + flight: { start: "2026-10-06T00:00:00Z", end: "2026-12-21T23:59:59Z" } + advertisers: [{ domain: "brand-a.example" }] + derived_units: 3333333 + hold_status: { hold_available: true, hold_duration: "P7D", hold_scope: line_item } + transaction_mechanism: { available: ["programmatic_guaranteed"], + included: ["programmatic_guaranteed"], max_select: 1 } + buyer_requirements: + seats: { dsp: "dsp-a.example", ids: ["seat-99812", "seat-99813"] } # the buyer's write + visibility: named + eligible_buyers: ["agency-a.example"] + terms_refs: + - { url: "https://examplepub.example/legal/io-terms-2026.pdf", + digest: "sha256:83e44f6fa723c35fef1b3528b16cc13a8a19ee34a32b658f6d3cc243ce12592e", version: "2026.1" } + - { url: "https://examplepub.example/legal/agency-a-q4-addendum.pdf", + digest: "sha256:ab4cac3cb240b5c6eec3d0bf40e8c0b0f21c2026837865d88a5c941702f2eeca" } + correlation_id: "ep-q4-2026-agency-a/business-daily-display" + + - line_item_id: "weather-app-ctv" + status: agreed + channel: ctv + properties: { available: [{ id: "weather-app" }], included: ["weather-app"] } + formats: + available: ["ctv/instream-15s"] + included: ["ctv/instream-15s"] + creative_delivery: { method: seller_hosted, protocol: vast_4_2, secure_required: true } + pricing: + - { cost_method: CPM, list_rate: 45.00, agreed_rate: 45.00, min_rate: 39.60, + price_valid_until: "2026-12-01T23:59:59Z", currency: USD } + commitment_bounds: { min_budget: 25000.00 } + commitment: + basis: spend + amount: 160000.00 + currency: USD + flight: { start: "2026-10-06T00:00:00Z", end: "2026-12-21T23:59:59Z" } + advertisers: [{ domain: "brand-a.example" }] + hold_status: { hold_available: false } # private_auction: no inventory reserved + transaction_mechanism: { available: ["private_auction"], included: ["private_auction"], max_select: 1 } + buyer_requirements: + seats: { dsp: "dsp-a.example", ids: ["seat-99812", "seat-99813"] } # the buyer's write + visibility: named + eligible_buyers: ["agency-a.example"] + terms_refs: + - { url: "https://examplepub.example/legal/io-terms-2026.pdf", + digest: "sha256:83e44f6fa723c35fef1b3528b16cc13a8a19ee34a32b658f6d3cc243ce12592e", version: "2026.1" } + - { url: "https://examplepub.example/legal/agency-a-q4-addendum.pdf", + digest: "sha256:ab4cac3cb240b5c6eec3d0bf40e8c0b0f21c2026837865d88a5c941702f2eeca" } + correlation_id: "ep-q4-2026-agency-a/weather-app-ctv" diff --git a/examples/proposal-issued.yaml b/examples/proposal-issued.yaml index 1396b90..2b6e8e7 100644 --- a/examples/proposal-issued.yaml +++ b/examples/proposal-issued.yaml @@ -20,13 +20,14 @@ description: > assent: - { role: commitment, version: 1, - record_digest: "sha256:cdcd75ebd66938fcd872cfc1bf66d10a3244758d7810125639beabe0dcf53430", + record_digest: "sha256:990b934f85b9eb16fbde1416d19cc0f73979a884cc9ec6031532f9bde5e9aac9", line_item_ids: ["business-daily-display"], - signed_at: "2026-10-05T10:14:03Z" } + at: "2026-10-05T10:14:03Z", + idempotency_key: "agency-a-camp1-business-daily-display-8f42c1a9" } - { role: acceptance, version: 1, - record_digest: "sha256:cdcd75ebd66938fcd872cfc1bf66d10a3244758d7810125639beabe0dcf53430", + record_digest: "sha256:990b934f85b9eb16fbde1416d19cc0f73979a884cc9ec6031532f9bde5e9aac9", line_item_ids: ["business-daily-display"], - signed_at: "2026-10-05T10:31:47Z" } + at: "2026-10-05T10:31:47Z" } negotiation_history: - { version: 1, actor: seller, action: published, paths: [], @@ -75,15 +76,12 @@ line_items: max_select: 1 selected: ["notice-7d"] # the buyer's write commitment: - proposal_id: "examplepub-agencya-q4-2026-omni" - version: 1 basis: spend amount: 80000.00 currency: USD flight: { start: "2026-10-12T00:00:00Z", end: "2026-12-21T23:59:59Z" } advertisers: [{ domain: "brand-a.example" }] derived_units: 2904865 # derived: 80000.00 / 27.54 × 1000 - idempotency_key: "agency-a-camp1-business-daily-display-8f42c1a9" hold_status: { hold_available: true, hold_duration: "P7D", hold_scope: line_item, state: none } visibility: named diff --git a/examples/proposal.yaml b/examples/proposal.yaml index f480e2c..6d1d312 100644 --- a/examples/proposal.yaml +++ b/examples/proposal.yaml @@ -16,8 +16,7 @@ negotiation_history: - { version: 1, actor: seller, action: published, paths: [], at: "2026-09-22T09:00:00Z" } - { version: 2, actor: seller, action: published, - paths: [{ line_item_id: "business-daily-display", pointer: "/creative_delivery" }, - { pointer: "/seasonality_notes" }], + paths: [{ line_item_id: "business-daily-display", pointer: "/creative_delivery" }], at: "2026-10-03T14:12:40Z" } description: > diff --git a/scripts/check_appendix.py b/scripts/check_appendix.py index ccad4b9..e4b2505 100755 --- a/scripts/check_appendix.py +++ b/scripts/check_appendix.py @@ -51,6 +51,7 @@ ("Layer", "Question", "Answered by", "Owner", "In the proposal object"), ("Interaction", "Actor", "Effect; history `action`"), ("Instant", "Ends", "Effect when passed"), + ("Rule", "Profile choice"), # Generated. ("Field", "Section", "Type", "Required", "Marker"), ("Marker", "Count", "Meaning", "Fields"), @@ -64,7 +65,6 @@ # 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 = { - "AssentEntry": "the assent entry", "PropertyOption": "the property option", "AudienceOption": "the audience option", "BuyerSupplied": "the buyer-supplied audience data", @@ -87,7 +87,6 @@ "`availability`": "waiting: the availability figure", "`commitment_bounds`": "waiting: the commitment bounds", "`creative_policy`": "waiting: the creative policy", - "`terms_ref`": "waiting: the binding document reference", } BODY_SECTION = re.compile(r"^#{3,4} ((?:4|5)\.\d+(?:\.\d+)?)\s") diff --git a/scripts/check_examples.py b/scripts/check_examples.py index ee898ca..733d967 100755 --- a/scripts/check_examples.py +++ b/scripts/check_examples.py @@ -20,8 +20,12 @@ each public, published line item (section 2.3). - seller and buyer on a proposal hold legal_name (section 4.1). - Each section 4.1.1 removal path resolves to a table row. - - Each assent[] entry's record_digest recomputes under section 4.1.1, over the - line items its line_item_ids[] names. + - record_digest in examples/proposal-agreed.yaml and examples/proposal-issued.yaml + recomputes under section 4.1.1, over the line items each entry names, and a + buyer_supplied that holds no key after the removals moves no digest. + - In those examples a withdrawal or decline matches a commitment entry before it, + an acceptance matches a live commitment entry, and a line item is agreed only + with a matching pair (section 4.1.2). KNOWN holds findings that a later change removes. It only shrinks. Run with --regen-manifest to rewrite examples/manifest.json from the full examples. @@ -80,8 +84,11 @@ def _committed_entry(item): lambda o, i, p: any(e.get("action") == "requested" and any(x is o for x in e.get("paths", [])) for e in (p or {}).get("negotiation_history", [])), "`invoice_period` is `cycle`": lambda o, i, p: o.get("invoice_period") == "cycle", - "`terms_acceptance_required` is `true`": - lambda o, i, p: i.get("terms_acceptance_required") is True, + "`role` is `commitment`": lambda o, i, p: o.get("role") == "commitment", + # No example holds a CommitResult. A ground of availability is the one sign of it. + "`entries[]` holds a `decline` for lack of availability": + lambda o, i, p: any(e.get("role") == "decline" and e.get("ground") == "availability" + for e in o.get("entries", [])), "`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, @@ -125,6 +132,7 @@ def _committed_entry(item): "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://"))), + "HttpsUrl": (lambda v: isinstance(v, str) and v.startswith("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)), @@ -663,9 +671,73 @@ def record_digest(doc, removal, line_item_ids): head, _, tail = path.partition(", except ") keep = re.split(r",\s*|\s+and\s+", tail) if tail else () remove_path(copy, head[2:].split("."), keep) + # Section 4.1.1: the one object the removals don't leave in place when it is empty. + for item in copy.get("line_items", []): + if item.get("buyer_supplied") == {}: + del item["buyer_supplied"] return "sha256:" + hashlib.sha256(jcs(copy).encode("utf-8")).hexdigest() +def check_digest_ignores_empty_buyer_supplied(checker, doc, removal): + """Section 4.1.1: a removed buyer_supplied value moves no digest, and a kept one does.""" + item = doc["line_items"][0] + forms = [ + None, + {}, + {"match_rate_expected": 0.65}, + {"segments": [], "suppression_list": {}, "match_rate_expected": 0.65}, + ] + digests = set() + for form in forms: + copy = json.loads(json.dumps(doc)) + copy["line_items"][0].pop("buyer_supplied", None) + if form is not None: + copy["line_items"][0]["buyer_supplied"] = form + digests.add(record_digest(copy, removal, [item["line_item_id"]])) + if len(digests) != 1: + checker.errors.append("record_digest: an absent and an empty buyer_supplied give different digests") + copy = json.loads(json.dumps(doc)) + copy["line_items"][0]["buyer_supplied"] = {"suppression_allowed": True} + if record_digest(copy, removal, [item["line_item_id"]]) in digests: + checker.errors.append("record_digest: a buyer_supplied that keeps a term moved no digest") + + +def check_assent(checker, file, doc): + """Section 4.1.2: only a matching pair makes agreed, and an acceptance matches a live entry.""" + def key(entry): + return entry["version"], entry["record_digest"], frozenset(entry["line_item_ids"]) + + items = {i["line_item_id"]: i for i in doc.get("line_items", [])} + commitments, live, matched, agreed = [], set(), set(), set() + for entry in doc.get("assent", []): + role = entry["role"] + if "ground" in entry and role != "decline": + checker.errors.append(f"{file}: {role} entry carries ground") + for named in entry["line_item_ids"]: + if named not in items: + checker.errors.append(f"{file}: {role} entry names {named}, which is not a line item") + elif role == "commitment" and "commitment" not in items[named]: + checker.errors.append(f"{file}: commitment entry names {named}, which has no commitment") + if role == "commitment": + commitments.append(key(entry)) + live.add(key(entry)) + elif role == "acceptance": + # Section 4.1.2: an acceptance matches only a live commitment entry. + if key(entry) not in live: + checker.errors.append(f"{file}: acceptance entry matches no live commitment entry") + else: + matched.add(key(entry)) + agreed.update(entry["line_item_ids"]) + elif key(entry) not in commitments: + checker.errors.append(f"{file}: {role} entry matches no commitment entry before it") + elif key(entry) not in matched: + live.discard(key(entry)) + for name, item in items.items(): + if (item.get("status") == "agreed") != (name in agreed): + checker.errors.append(f"{file}: {name} has status {item.get('status')!r}, " + f"and assent[] {'has' if name in agreed else 'has no'} matching pair") + + # Summary ------------------------------------------------------------------- @@ -773,14 +845,17 @@ def main(): checker.errors.append(f"manifest.json: {e}") checker.check_removal_paths() + check_digest_ignores_empty_buyer_supplied(checker, load_yaml(EXAMPLES / "proposal-agreed.yaml"), spec.removal) + digests = {} - for path in sorted(EXAMPLES.glob("*.yaml")): - doc = load_yaml(path) + for name in ("proposal-agreed.yaml", "proposal-issued.yaml"): + doc = load_yaml(EXAMPLES / name) + check_assent(checker, name, doc) for entry in doc.get("assent", []): digest = record_digest(doc, spec.removal, entry["line_item_ids"]) - digests[path.name] = digest + digests[name] = digest if entry["record_digest"] != digest: - checker.errors.append(f"{path.name}: record_digest recomputes to {digest}") + checker.errors.append(f"{name}: record_digest recomputes to {digest}") stale = KNOWN - checker.used_known for item in sorted(stale): diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 1ebb0ff..a7b8b6f 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,6 +5,40 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] +- § 1.2, § 2.2, § 2.4, § 2.8, § 3.4, § 4, § 4.1, § 4.1.1, § 4.1.2, § 4.1.3, § 4.2, § 4.3, + § 4.3.1, § 4.3.2, § 4.3.4, § 5.5, § 5.6, § 5.6.1, § 5.8, § 5.9, § 5.10, § 6, § 7, appendices, + examples, checkers — what is signed, and how a seller checks it. `record_digest` sits only on + `assent[]` entries, and the document-level field and its "two values" are gone. § 4.1.1 states + the exact bytes of the digest input. A removal leaves an empty object in place, except + `buyer_supplied`, and nothing fills in a default. `agreed_rate` takes the § 5.6 result, a + `DocumentRef` enters whole, and the YAML conversion and validity tests are stated. The removal + list drops `seller_rate`, `deal_id_issuance`, `commitment.version`, and + `commitment.idempotency_key`. It adds `min_rate`, `best_for[]`, `not_suitable_for[]`, + `seasonality_notes`, `segments[]`, and `suppression_list`. `AssentEntry` has a key table: `role` + is `commitment`, `acceptance`, `withdrawal`, or `decline`, and `signed_at` becomes `at`. + `signer` is removed, and `idempotency_key` moves here from `commitment`. Only `assent[]` decides + whether a line item is agreed, and a history entry voids nothing. A `commitment` entry stops + being live when its `commit` goes stale or void, and an `acceptance` matches only a live entry. + A `Commit` holds the buyer's entry and its `writes`. A `CommitResult` holds the entries and any + `availability` figures a `decline` states. The seller's check on `version` becomes a recomputed + digest. Its rejections come in order: `expired`, `stale_version`, `not_committable`, then + `record_mismatch`. A `commit` writes an entry only on an issued proposal, and a replay returns + the result as it stands. An entry travels in the body of an HTTP message signed under the + OpenProposal profile of Web Bot Auth (§ 2.8). `Content-Digest` covers the body, and requests + and responses are both signed. Which signing origins a party accepts is its own configuration, + and any signed copy of an entry is evidence of it. The evidence is OPTIONAL, and `agreed` + doesn't depend on it. Each party keeps the agreed record. `terms_ref` and + `terms_acceptance_required` become `terms_refs[]` of `DocumentRef`, and a `commitment` entry + accepts them all. An absent list accepts no document. A `DocumentRef`'s `url` is an `HttpsUrl`, + a new type, and `list_ref` is a `DocumentRef`. `commitment.proposal_id` and + `commitment.version` go, because the entry pins the version. `seller_rate` is removed. Breaking + ([#63](https://github.com/IABTechLab/OpenProposal/issues/63), + [#27](https://github.com/IABTechLab/OpenProposal/issues/27), + [#24](https://github.com/IABTechLab/OpenProposal/issues/24), + [#34](https://github.com/IABTechLab/OpenProposal/issues/34), + [#45](https://github.com/IABTechLab/OpenProposal/issues/45), + [#73](https://github.com/IABTechLab/OpenProposal/issues/73), + [#75](https://github.com/IABTechLab/OpenProposal/issues/75)). - § 1.1, § 2.1, § 2.2, § 2.6, § 3, § 3.1 to § 3.4, § 4.1, § 4.1.1, § 4.1.3, § 4.3.1, § 4.3.2, § 5.1.1, § 5.2, § 5.2.1, § 5.4, § 5.5, § 5.6, § 5.6.1, § 5.6.3, § 5.7, § 5.9, § 6, appendices, examples, checker — what the buyer writes, and how the rate is computed. diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index ef5ee39..c028a17 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -62,14 +62,15 @@ seller alike. An answer to one is not an answer to another. | Layer | Question | Answered by | Owner | In the proposal object | | :--- | :--- | :--- | :--- | :--- | -| Request authentication | Which origin sent this request? | An [HTTP Message Signature](https://www.rfc-editor.org/rfc/rfc9421), with the key in that origin's [Web Bot Auth](https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/) directory | The AAMP agent identity and signing profile (provisional name) | Nothing. The object never carries or re-proves it | +| Message authentication | Which origin sent this request or response? | An [HTTP Message Signature](https://www.rfc-editor.org/rfc/rfc9421), with the key in that origin's [Web Bot Auth](https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/) directory | This specification's Web Bot Auth profile (§ 2.8) | Nothing. The object never carries or re-proves it | | Authorization | What may this party, or its agent, do? | The seller's eligibility rules; a mandate the principal publishes or signs | This specification for the rules (§ 5.8); the AAMP authority document (provisional name) for mandates | The rules only, never the proof | -| Legal assent | Which legal entity agreed to which terms? | The `assent[]` entries for the binding version, with both parties named in the proposal | This specification for the parties and the record (§ 4.1); the profile for any signature over it | Parties, `assent[]` | +| Legal assent | Which legal entity agreed to which bytes? | Two matching `assent[]` entries over one `record_digest`, with both parties named in the record; as optional evidence, the signed messages that carried them | This specification for the record, digest, entry, and what each party keeps (§ 4.1.1, § 4.1.2); § 2.8 for how a message is signed and its key found | Parties, digest, entries | -A request signature proves an `https` origin. It does not prove which party that +A message signature proves an `https` origin. It does not prove which party that origin speaks for: a hosted agent's origin is the host's, and a mandate the party -publishes or signs connects them. What authenticates a seller's response is the -profile's to state. Reading a public file (§ 2.3) needs none of this. +publishes or signs connects them. An `assent[]` entry travels in the body of a +signed message, as optional evidence, and § 4.1.2 states when the signature +attributes it to a party. Reading a public file (§ 2.3) needs none of this. --- @@ -189,6 +190,7 @@ shared type or changes its form. | `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 | +| `HttpsUrl` | A `Url` whose scheme is `https` | A location fetched over TLS | | `FieldPointer` | String: a JSON Pointer ([RFC 6901](https://www.rfc-editor.org/rfc/rfc6901)) | A location in the object this specification defines | | `Ext` | `Map[Domain, Object]` | The extension container. § 2.5 states its rules | @@ -210,7 +212,7 @@ retrievable, and the counterparty stores a copy. | Key | Type | Required | Meaning | | :--- | :--- | :--- | :--- | -| `url` | Url | REQUIRED | The document. It MAY need authentication | +| `url` | HttpsUrl | 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. A `proposal_id` @@ -312,8 +314,8 @@ A party that rejects a request MUST return `errors[]`, one or more the request carries the errors. A seller's decline of a resolved `commitment`, or of a hold, answers a valid -request and is not an error. What a party answers when request authentication -fails is the AAMP agent identity and signing profile's to state. +request and is not an error. What a party answers when a request fails message +authentication is § 2.8's to state. **`ErrorObject`** keys: @@ -323,7 +325,7 @@ fails is the AAMP agent identity and signing profile's to state. | `detail` | String | REQUIRED | Human-readable. A receiver SHOULD NOT parse it | | `pointer` | FieldPointer | OPTIONAL | Into the object this specification defines that the request carries, to the value that caused the error | | `version` | Integer | REQUIRED when the request names a proposal the requester may read | The proposal's `version` when the error was decided | -| `retry` | Enum | REQUIRED | `same` \| `refetch` \| `none`. `same`: the same request can succeed later. `refetch`: a new attempt, composed on the current `version`, can succeed. `none`: only a changed request can succeed. On a commitment attempt, `same` keeps the `idempotency_key` and such an error is not a stored result (§ 5.6.1), and `refetch` and `none` take a new `idempotency_key` | +| `retry` | Enum | REQUIRED | `same` \| `refetch` \| `none`. `same`: the same request can succeed later. `refetch`: a new attempt, composed on the current `version`, can succeed. `none`: only a changed request can succeed. On a `commit`, `same` keeps the `idempotency_key` and such an error is not a stored result (§ 5.6.1), and `refetch` and `none` take a new `idempotency_key` | Core codes: `validation` \| `not_found` \| `not_offered` \| `unresolved_reference` \| `not_committable` \| `stale_version` \| `expired` \| `idempotency_conflict` \| @@ -335,12 +337,12 @@ Core codes: `validation` \| `not_found` \| `not_offered` \| `unresolved_referenc | `not_found` | A named proposal or line item does not exist, or the requester may not read it (§ 5.8) | | `not_offered` | A buyer value is outside what the seller declared (§ 3.1, § 3.2, § 4.3.2, § 5.5) | | `unresolved_reference` | A `DocumentRef` (§ 2.2) does not resolve, or its content does not match its `digest` | -| `not_committable` | A line item's `status` rules out an ask (§ 4.3.2), a `requires` that is unmet rules out a `commit` (§ 5.6.3), or a seller rejects a `commit` before `offer_window.start` (§ 5.6.1) | +| `not_committable` | A line item's `status` rules out an ask (§ 4.3.2), a `requires` that is unmet rules out a `commit` (§ 5.6.3), a live `commitment` entry already names a line item (§ 4.1.2), or a seller rejects a `commit` before `offer_window.start` (§ 5.6.1) | | `stale_version` | An ask is stale (§ 4.1.3) | | `expired` | `offer_window.end` or `price_valid_until` has passed | -| `idempotency_conflict` | An `idempotency_key` is reused with a different body | +| `idempotency_conflict` | An `idempotency_key` is reused with a different `Commit` (§ 5.6.1) | | `not_eligible` | The buyer or the sender fails an eligibility or authority check the seller applies (§ 5.8) | -| `record_mismatch` | A `record_digest` in the request does not equal the one the receiver recomputes (§ 4.1.1) | +| `record_mismatch` | A `record_digest` in the request does not equal the one the receiver recomputes, and § 5.6.1 gives no other code (§ 4.1.1) | An `ErrorObject` is open. A receiver that does not recognize a code, or a key of the object, acts on `retry`. @@ -477,6 +479,31 @@ it before reading the bytes, and `unresolved_reference` (§ 2.4) reports a misma people. A party that holds them limits who can read them, in stored records and logs as well as in responses. § 5.8 states the disclosure rule for `exclusions`. +### 2.8 Web Bot Auth profile + +This section is the OpenProposal profile of [Web Bot Auth](https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/), the IETF +protocol for signing automated HTTP traffic with +[HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421). It selects options +those documents define and adds no header, tag, key field, or error code. A party +that signs an OpenProposal request or response signs it under this profile. Signing +is OPTIONAL: § 4.1.2 states what a signature shows, and `agreed` never depends on one. +When the AAMP agent identity and signing profile (provisional name) is published, it +replaces this section. + +| Rule | Profile choice | +| :--- | :--- | +| Identity | An `https` origin. The signer names it in a covered `Signature-Agent` member, as the ASCII serialization of the origin ([RFC 6454 § 6.2](https://www.rfc-editor.org/rfc/rfc6454#section-6.2)): a lowercase host, no default port, and no path, query, fragment, or userinfo | +| Keys | The origin's key directory, at `/.well-known/http-message-signatures-directory` with the media type `application/http-message-signatures-directory+json`. Each signing key is an Ed25519 key (`kty` `OKP`, `crv` `Ed25519`), and its `kid` is its JWK thumbprint ([RFC 7638](https://www.rfc-editor.org/rfc/rfc7638)) | +| Signature | One signature, with `tag="web-bot-auth"`, `alg="ed25519"`, `keyid` set to the key's thumbprint, `created`, and `expires` no more than 300 seconds after `created`. A request also carries a `nonce` | +| Request components | `@method`, `@target-uri`, `@authority`, and `"signature-agent";key=