From b70a5c3eda27d08cdf09129c81cbceaf810c139d Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Thu, 22 Oct 2026 15:56:03 +0530 Subject: [PATCH 01/11] 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 8e1f614a5c0546e88d993ed239ffcdc71b395a78 Mon Sep 17 00:00:00 2001 From: Kishore2572 Date: Thu, 22 Oct 2026 17:16:07 +0530 Subject: [PATCH 02/11] 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 ecec3d74f484dbb4ea4404fb7fe847cbbd94aafe Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Fri, 23 Oct 2026 18:50:47 +0530 Subject: [PATCH 03/11] 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 7ab6d2b39d688b92790336cf7c2f2604f012a605 Mon Sep 17 00:00:00 2001 From: Aleksander Sekowski Date: Sat, 24 Oct 2026 10:46:41 -0700 Subject: [PATCH 04/11] 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 8fde92cefc82e806c71889fe0468032f6943d314 Mon Sep 17 00:00:00 2001 From: Aleksander Sekowski Date: Sat, 24 Oct 2026 11:37:06 -0700 Subject: [PATCH 05/11] 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 06/11] 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 0125d5549c4cc1790bd3ab847e8d5826b66de82d Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 25 Oct 2026 00:20:18 -0400 Subject: [PATCH 07/11] 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 395ca8357533843450083d1676b6cc6cab2c87c0 Mon Sep 17 00:00:00 2001 From: Aleksander Sekowski Date: Sun, 25 Oct 2026 11:03:04 -0700 Subject: [PATCH 08/11] 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 10a3780a91a21251567a9fac579695efc3fee088 Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Wed, 28 Oct 2026 21:20:21 +0530 Subject: [PATCH 09/11] 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 e47cbf566fd6e4d5c0a36c1bedda630775513a76 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Tue, 3 Nov 2026 23:28:42 +0100 Subject: [PATCH 10/11] 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 acecc8ec54b29203f3c88cc4831f06790cdba50c Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Thu, 5 Nov 2026 10:52:15 +0100 Subject: [PATCH 11/11] Point the inspection entry at section 5.4.1 Match the heading number and the other CHANGELOG entries. --- spec/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index c69fd6f..3aa6009 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -33,7 +33,7 @@ issue that caused it, so the comment record and the spec history line up. the summary carries `environments.available[]` ([#90](https://github.com/IABTechLab/OpenProposal/issues/90)). - Define the boundary between creative inspection and production notifications - in section 5.4, with an informative CTV example note + in section 5.4.1, with an informative CTV example note ([#42](https://github.com/IABTechLab/OpenProposal/issues/42)). - § 2, § 4.1, § 5.6 — `linked_execution` on the proposal and `requires` on line items, for linked execution and bonus line items