diff --git a/.github/ISSUE_TEMPLATE/comment.yml b/.github/ISSUE_TEMPLATE/comment.yml index 2549c24..640c20e 100644 --- a/.github/ISSUE_TEMPLATE/comment.yml +++ b/.github/ISSUE_TEMPLATE/comment.yml @@ -47,7 +47,7 @@ body: attributes: label: Field(s) involved description: Comma-separated, if your comment is about specific fields. - placeholder: "pricing[].floor, transaction_mechanism" + placeholder: "pricing[].min_rate, transaction_mechanism" - type: textarea id: comment attributes: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 83f6576..568faef 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -8,6 +8,6 @@ contact_links: - name: Read the comment process first url: https://github.com/IABTechLab/OpenProposal/blob/main/docs/comment-process.md about: Timeline, dispositions, and what happens to your comment. - - name: The seven open questions + - name: The open questions url: https://github.com/IABTechLab/OpenProposal/blob/main/docs/open-questions.md about: Where the working group is genuinely undecided. Highest-value place to comment. diff --git a/.github/labels.yml b/.github/labels.yml index f3a7023..bee9d93 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -10,7 +10,7 @@ description: Proposes specific normative wording - name: open-question color: "d93f0b" - description: Relates to one of the 7 open questions in § 8 + description: Relates to one of the open questions in § 8 - name: implementation-feedback color: "0e8a16" description: From someone who built against the draft diff --git a/.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/CONTRIBUTING.md b/CONTRIBUTING.md index 920fa44..b3e2522 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ GitHub account may file an issue or a pull request. thread rather than opening a duplicate, so the disposition covers one discussion, not five. - Check [docs/open-questions.md](docs/open-questions.md). If your point lands on - one of the seven questions the draft explicitly asks, comment there. + one of the questions the draft explicitly asks, comment there. ## 2. Pick the right route @@ -24,7 +24,7 @@ GitHub account may file an issue or a pull request. | :--- | :--- | :--- | | **Comment** issue | Something is unclear, wrong, internally inconsistent, or missing. You are not proposing wording. | Triaged, dispositioned, logged | | **Change request** issue | You are proposing specific normative wording, a new field, a changed marker, or a removal. | Triaged, dispositioned, logged | -| **Open question** comment | You are answering one of the 7 questions in § 8. | Feeds the resolution of that question | +| **Open question** comment | You are answering one of the questions in § 8. | Feeds the resolution of that question | | **Implementation feedback** issue | You built against the draft and hit something. **This is the most valuable kind of comment we receive.** | Triaged with priority | | **Pull request** | You want to supply the exact diff for a change already discussed. | Reviewed against its issue | | **Discussion** | You have a question, or want to think out loud before filing. | No disposition; not part of the formal record | diff --git a/README.md b/README.md index 8314672..6deafd8 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ server. | :--- | :--- | | **Current draft** | [`spec/openproposal-3.0.md`](spec/openproposal-3.0.md) | | **Comment period opens** | 2026-09-22 | -| **Comment period closes** | 2026-10-22 | +| **Comment period closes** | 2026-11-10 | | **Announcement** | [IAB Tech Lab introduces AAMP 3.0 with OpenProposal](https://iabtechlab.com/press-releases/iab-tech-lab-introduces-aamp-3-0-with-openproposal/) | | **Short link** | [iabtechlab.com/AAMPv3](https://iabtechlab.com/AAMPv3) (points to this repository) | | **How to comment** | [CONTRIBUTING.md](CONTRIBUTING.md) | @@ -56,7 +56,7 @@ Pick the route that matches what you have to say: | :--- | :--- | | Flag something unclear, wrong, or missing | [Open a **Comment**](https://github.com/IABTechLab/OpenProposal/issues/new?template=comment.yml) | | Propose specific normative wording | [Open a **Change request**](https://github.com/IABTechLab/OpenProposal/issues/new?template=change-request.yml), or send a pull request against `spec/openproposal-3.0.md` | -| Answer one of the 7 open questions | Comment on that question's [tracking issue](docs/open-questions.md) | +| Answer one of the open questions | Comment on that question's [tracking issue](docs/open-questions.md) | | Report what happened when you tried to implement it | [Open **Implementation feedback**](https://github.com/IABTechLab/OpenProposal/issues/new?template=implementation-feedback.yml) | | Ask a question rather than raise an issue | Use [Discussions](https://github.com/IABTechLab/OpenProposal/discussions) | @@ -69,15 +69,15 @@ in [docs/disposition-log.md](docs/disposition-log.md). See Three things to understand before the field tables make sense: -1. **No inheritance.** Every line item is fully self-contained. A field never +1. **No inheritance.** Every term of a line item is on the line item. A field never takes its value from the parent proposal or a sibling line item. 2. **Every field carries exactly one mutability marker** (§ 3) — `immutable`, - `seller-set`, `selectable`, `settable`, `supplied`, `requestable` or - `derived`. The marker is what tells a buying agent whether it can act - unilaterally. -3. **Composition is constraint satisfaction, not negotiation.** Of 71 fields, - only 3 require a round trip with the counterparty. That ratio is the design - claim the draft is asking you to test. + `seller-set`, `selectable`, `settable`, `supplied`, `requestable`, `derived`, + `lifecycle`, `recorded`, `asserted` or `mixed`. The marker is what tells a + buying agent whether it can act unilaterally. +3. **Composition is constraint satisfaction, not negotiation.** Only `requestable` + fields require a round trip with the counterparty to resolve. Appendix B has the + count. That ratio is the design claim the draft is asking you to test. ## Downstream standards @@ -89,8 +89,8 @@ OpenRTB 2.6). See § 1.1. Machine-readable schemas are coming. They are not published with this first draft, because the comment period is still deciding questions that shape -them. They will follow the first revision, after the comment period closes on -2026-10-22. Until then, [`examples/`](examples/) is the best reference for +them. They will follow the first revision, after the comment period closes. +Until then, [`examples/`](examples/) is the best reference for shape. See [schema/README.md](schema/README.md). ## Contributors and governance diff --git a/docs/comment-process.md b/docs/comment-process.md index ed2abe8..40e5d69 100644 --- a/docs/comment-process.md +++ b/docs/comment-process.md @@ -8,14 +8,14 @@ or a documented decision not to change it. | Phase | Dates | What happens | | :--- | :--- | :--- | | **1. Comment period opens** | 2026-09-22 | Draft frozen at a tagged version. Announcement published. Issues open. | -| **2. Comment period** | 2026-09-22 → 2026-10-22 (30 days) | Anyone may file. Maintainers triage and label within 5 business days of filing, but do not resolve. | -| **3. Comment period closes** | 2026-10-22 | No new comments enter the formal record. Later issues are labelled `post-comment` and considered for the following draft. | +| **2. Comment period** | 2026-09-22 → 2026-11-10 | Anyone may file. Maintainers triage and label within 5 business days of filing, but do not resolve. | +| **3. Comment period closes** | 2026-11-10 | No new comments enter the formal record. Later issues are labelled `post-comment` and considered for the following draft. | | **4. Adjudication** | `TBD` → `TBD` | Working group works the queue. Every open comment gets a disposition. | | **5. Disposition published** | `TBD` | [disposition-log.md](disposition-log.md) published in full. Commenters notified on their issue. | | **6. Revised draft** | `TBD` | New draft tagged. Either ratification or a second comment period, depending on how much moved. | > **Maintainers: phases 4–6 are still `TBD`.** Fill them before the comment -> period closes on 2026-10-22. +> period closes. ## Dispositions @@ -59,7 +59,7 @@ Triage labels, applied on filing: | :--- | :--- | | `comment` | General comment on the draft | | `change-request` | Proposes specific normative wording | -| `open-question` | Relates to one of the 7 questions in § 8 | +| `open-question` | Relates to one of the questions in § 8 | | `implementation-feedback` | From someone who built against the draft | | `editorial` | Typo, formatting, broken link — no normative effect | | `normative` | Changes what the spec requires | diff --git a/docs/disposition-log.md b/docs/disposition-log.md index 52ec2eb..639c89c 100644 --- a/docs/disposition-log.md +++ b/docs/disposition-log.md @@ -3,7 +3,7 @@ The formal record of every comment filed during the OpenProposal 3.0 public comment period, and what was decided. -**Status: comment period open (2026-09-22 → 2026-10-22). No dispositions yet.** +**Status: comment period open (2026-09-22 → 2026-11-10). No dispositions yet.** This file is updated as comments are adjudicated and published in full at the end of the period. See [comment-process.md](comment-process.md) for what each diff --git a/docs/open-questions.md b/docs/open-questions.md index d8d32ba..4d344b7 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -1,7 +1,7 @@ # Open questions -The draft asks seven questions it does not answer. These are the highest-value -places to comment, because the working group is genuinely undecided. +These are the questions the draft asks and does not answer. They are the +highest-value places to comment, because the working group is genuinely undecided. Each has its own tracking issue, labelled [`open-question`](https://github.com/IABTechLab/OpenProposal/issues?q=label%3Aopen-question). @@ -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,21 +91,46 @@ property). --- -### OQ-7 — `environments[]` needs validation against real inventory +### OQ-7 — `environments.available[]` needs validation against real inventory **Issue:** [#8](https://github.com/IABTechLab/OpenProposal/issues/8) · **Spec:** § 5.2 -The enum — `web_desktop`, `web_mobile`, `app_mobile`, `ctv`, `dooh`, -`audio_streaming` — is proposed, not derived from implementation experience. +The enum — `web_desktop`, `web_mobile`, `app_mobile`, `ctv`, `dooh`, each +defined in § 5.2.1 — is proposed, not derived from implementation experience. **What would settle it:** sellers mapping their real inventory onto the enum and reporting what does not fit. --- +### OQ-8 — How does a receiver read a document from a later release? + +**Issue:** [#131](https://github.com/IABTechLab/OpenProposal/issues/131) · **Spec:** § 2.5, § 4.1 + +`spec_version` names the release a document follows, and § 2.5 makes a key the +receiver's release does not list invalid. Nothing says whether a receiver rejects a +release it does not know, or keeps and ignores keys it does not list. + +**What would settle it:** how an implementer upgrades a seller and its buyers when +they don't move at the same time, and whether unread keys can reach an assent. + +--- + +### OQ-11 — Must a manifest response carry both `ETag` and `Last-Modified`? + +**Issue:** [#29](https://github.com/IABTechLab/OpenProposal/issues/29) · **Spec:** § 2.3 + +The draft requires at least one, so a buyer's client handles whichever the seller +sends. + +**What would settle it:** which validator polling clients use, and what the hosts +that serve the manifest send by default. + +--- + ## Not on this list but worth saying If you think the draft has a problem it has not asked about, file it as a [Comment](https://github.com/IABTechLab/OpenProposal/issues/new?template=comment.yml) or a -[Change request](https://github.com/IABTechLab/OpenProposal/issues/new?template=change-request.yml). The seven +[Change request](https://github.com/IABTechLab/OpenProposal/issues/new?template=change-request.yml). The questions above are where we know we are undecided, not the limit of what we will consider. diff --git a/docs/repo-setup.md b/docs/repo-setup.md index e83bfa7..629e8d5 100644 --- a/docs/repo-setup.md +++ b/docs/repo-setup.md @@ -52,7 +52,7 @@ unchecked are outstanding. ## Tracking issues -- [ ] **File the seven open-question issues** and fill the links in +- [ ] **File the open-question issues** and fill the links in [`open-questions.md`](open-questions.md). Label each `open-question` and pin them. These are where you most want comments to land, so they need to exist before anyone arrives. @@ -82,7 +82,7 @@ were closed. - [ ] State the closing date. - [ ] State explicitly that non-members may comment — otherwise most of the market assumes it cannot. -- [ ] Point at the seven open questions, not just the spec. Asking a specific +- [ ] Point at the open questions, not just the spec. Asking a specific question gets a specific answer; "thoughts welcome" gets silence. ## During the period diff --git a/examples/line-item-audio.yaml b/examples/line-item-audio.yaml index 4ca2f21..fc6fe28 100644 --- a/examples/line-item-audio.yaml +++ b/examples/line-item-audio.yaml @@ -2,34 +2,36 @@ line_item_id: "streaming-audio" status: published channel: audio -environments: { available: ["audio_streaming"], included: ["audio_streaming"] } +environments: { available: ["web_mobile", "app_mobile"], + included: ["web_mobile", "app_mobile"] } formats: { available: ["audio/host-read-30s", "audio/produced-30s"], included: ["audio/produced-30s"], - pricing: { "audio/host-read-30s": { delta_cpm: 9.00 } } } + deltas: { "audio/host-read-30s": { delta_cpm: 9.00 } } } production: - available: [{ id: "seller_built", build_owner: seller, lead_time: "10 business days" }, - { id: "buyer_supplied", build_owner: buyer, lead_time: "0" }] + available: [{ id: "seller_built", build_owner: seller, lead_time: "P14D" }, + { id: "buyer_supplied", build_owner: buyer, lead_time: "P0D" }] included: ["buyer_supplied"] - pricing: { seller_built: { delta_flat: 12000.00 } } + deltas: { seller_built: { delta_flat: 12000.00 } } -frequency_cap: { max: { min: 1, max: 6 }, period: "day", basis: listener } +markets: # priced by market (§ 5.5) + available: ["US", "CA", "GB"] + included: ["US"] + deltas: { "CA": { delta_cpm: 1.50 }, "GB": { delta_cpm: 3.00 } } + max_select: 3 + +frequency_cap: { count: { min: 1, max: 6, default: 3 }, period: day, per: listener } +time_zone: viewer_local pricing: - - { cost_method: CPM, gross_rate: 18.00, floor: 15.84, + - { cost_method: CPM, list_rate: 18.00, min_rate: 15.84, price_valid_until: "2026-12-15T23:59:59Z", currency: USD } commitment_bounds: { min_budget: 5000.00 } -commitment: - basis: spend - amount: 60000.00 - currency: USD - flight_start: "2026-10-06" - flight_end: "2026-12-21" - 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"], max_select: 1 } serving_mode: dai correlation_id: "ep-q4-2026-commitment/streaming-audio" diff --git a/examples/line-item-ctv-bonus.yaml b/examples/line-item-ctv-bonus.yaml new file mode 100644 index 0000000..18ec77e --- /dev/null +++ b/examples/line-item-ctv-bonus.yaml @@ -0,0 +1,30 @@ +# OpenProposal 3.0 — Example: Bonus line item (spec § 6.5) +# Illustrative only. A CTV bonus unlocked when the buyer commits to the display +# and CTV line items together, with at least 50,000 USD on display. Its price is +# its own: nothing is inherited from the line items it requires. +line_item_id: "weather-app-ctv-bonus" +status: published +channel: ctv +properties: { available: [{ id: "weather-app" }], included: ["weather-app"] } +environments: { available: ["ctv"], included: ["ctv"] } +formats: { available: ["ctv/instream-15s"], included: ["ctv/instream-15s"] } +creative_delivery: { method: seller_hosted, protocol: vast_4_2, secure_required: true } + +frequency_cap: { count: { min: 1, max: 2, default: 1 }, period: day, per: household } +time_zone: viewer_local + +requires: + all_of: ["business-daily-display", "weather-app-ctv"] + min_commitments: + - { line_item_id: "business-daily-display", basis: spend, amount: 50000.00, currency: USD } + +pricing: + - { cost_method: FLAT, list_rate: 0.00, + price_valid_until: "2026-12-01T23:59:59Z", currency: USD } + +commitment_bounds: { max_impressions: 500000 } + +visibility: registry_gated # its requires names the gated display line item +buying_route: SSP_PLATFORM +transaction_mechanism: { available: ["private_auction"], included: ["private_auction"], max_select: 1 } +serving_mode: ssai diff --git a/examples/line-item-ctv.yaml b/examples/line-item-ctv.yaml index fda3994..56c57ec 100644 --- a/examples/line-item-ctv.yaml +++ b/examples/line-item-ctv.yaml @@ -6,30 +6,26 @@ 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, default: 3 }, period: day, per: household } +time_zone: viewer_local pricing: - - { cost_method: CPM, gross_rate: 45.00, floor: 39.60, + - { cost_method: CPM, list_rate: 45.00, min_rate: 39.60, price_valid_until: "2026-12-01T23:59:59Z", currency: USD } commitment_bounds: { min_budget: 25000.00 } -commitment: - basis: spend - amount: 160000.00 - currency: USD - flight_start: "2026-10-06" - flight_end: "2026-12-21" - idempotency_key: "agency-a-camp1-weather-app-ctv-3b7e0d52" hold_status: { hold_available: true, hold_duration: "P14D", - hold_scope: line_item, state: held, - hold_expires_at: "2026-10-01T00:00:00Z" } + hold_scope: line_item } committed_metrics: [{ metric: completion_rate, value: 0.95, basis: seller_reported }] +visibility: public buying_route: SSP_PLATFORM -transaction_mechanism: { available: ["PRIVATE_AUCTION"], included: ["PRIVATE_AUCTION"] } +transaction_mechanism: { available: ["private_auction"], included: ["private_auction"], max_select: 1 } serving_mode: ssai correlation_id: "ep-q4-2026-commitment/weather-app-ctv" + +# 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..e203f42 100644 --- a/examples/line-item-display.yaml +++ b/examples/line-item-display.yaml @@ -1,6 +1,5 @@ # OpenProposal 3.0 — Example: Display line item, fully worked (spec § 6.2) -# Shows selectable audiences with per-option pricing, a catalog reference, -# a buyer commitment, and settable bounds. +# Shows selectable audiences with per-option pricing and settable bounds. line_item_id: "business-daily-display" status: published channel: display @@ -9,7 +8,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,84 +17,95 @@ 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 suppression_allowed: true match_rate_expected: 0.65 - suppression_list: { source: "advertiser_crm", size: 340000, match_method: "ramp_id" } formats: available: ["display/300x250", "display/728x90", "display/970x250"] 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" - -geo: { allowed: ["US-NY", "US-CA", "US-IL", "US-TX"] } -frequency_cap: { max: { min: 1, max: 5 }, period: "day", basis: device } +materials_due: { at: "2026-09-24T23:59:59Z" } + +geo: + allowed: ["US-NY", "US-CA", "US-IL", "US-TX"] + default: { include: ["US-NY", "US-CA", "US-IL", "US-TX"] } +device: { allowed: [1, 2, 4, 5], default: { include: [1, 2] } } # AdCOM 1.0 Device Types; 1 covers 4 and 5 +daypart: + allowed: [{ days: [monday, tuesday, wednesday, thursday, friday], start: "06:00", before: "22:00" }] + default: { include: [{ days: [monday, tuesday, wednesday, thursday, friday], start: "06:00", before: "22:00" }] } +frequency_cap: { count: { min: 1, max: 5, default: 3 }, period: day, per: device } +time_zone: "America/New_York" + +exclusions_accepted: # what this seller can enforce (§ 5.5) + types: [domain, iab_content_category, keyword] + max_entries: { domain: 5000, keyword: 500 } + enforcement: { domain: pre_serve, iab_content_category: pre_serve, keyword: best_effort } pricing: - cost_method: CPM - gross_rate: 24.00 - agreed_rate: 24.00 # derived: no priced options included + list_rate: 24.00 seller_rate: 20.40 - floor: 21.00 + min_rate: 21.00 price_valid_until: "2026-11-15T23:59:59Z" currency: USD commitment_bounds: { min_budget: 10000.00, max_budget: 500000.00 } -commitment: - basis: spend - amount: 80000.00 - currency: USD - flight_start: "2026-10-06" - flight_end: "2026-12-21" - derived_units: 3333333 # derived: 80000.00 / 24.00 × 1000 - idempotency_key: "agency-a-camp1-business-daily-display-8f42c1a9" - hold_status: { hold_available: true, hold_duration: "P7D", - hold_scope: line_item, state: none } + hold_scope: line_item } committed_metrics: - { metric: viewable_rate, value: 0.70, basis: mrc } measurement_source: - available: [{ vendor: "IAS", attribution_window: "30d" }, - { vendor: "DoubleVerify", attribution_window: "30d" }] - included: ["IAS"] + available: [{ id: ias, vendor: "IAS", attribution_window: "P30D" }, + { id: dv, vendor: "DoubleVerify", attribution_window: "P30D" }] + included: ["ias"] shortfall_remedy: { available: ["makegood", "credit"], included: ["makegood"] } reporting: - available: [{ feed_type: aggregate, cadence: daily, delivery_mechanism: api }, - { feed_type: log_level, cadence: daily, delivery_mechanism: s3 }] - included: ["aggregate"] - pricing: { log_level: { delta_flat: 2500.00 } } + available: [{ id: daily-aggregate, feed_type: aggregate, cadence: day, delivery_mechanism: api }, + { id: daily-log-level, feed_type: log_level, cadence: day, delivery_mechanism: s3 }] + included: ["daily-aggregate"] + deltas: { daily-log-level: { delta_flat: 2500.00 } } visibility: registry_gated -required_credentials: [{ type: dsp_seat, scheme: jwt_keypair, - issuer: "dsp-a", id_required: true }] +required_credentials: [{ credential_type: iab_registry, issuer: "registry.iabtechlab.ai" }] +terms_acceptance_required: true +terms_ref: { url: "https://examplepub.example/legal/io-terms-2026.pdf", + sha256: "", version: "2026.1" } 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"], max_select: 1 } serving_mode: client_side -buyer_requirements: { seat_required: true, dsp_allowed: ["dsp-a", "dsp-b"], - dsp_seat_id: "seat-99812", dsp_id: "dsp-a-prod", - seat_owner_entity: "Agency A" } +buyer_requirements: { seats_expected: true, + allowed_dsps: ["dsp-a.example", "dsp-b.example"], + seats: { dsp: "dsp-a.example", ids: ["seat-99812"] }, # the buyer's write + seat_owner: { domain: "agency-a.example", legal_name: "Agency A LLC" } } +billing: { invoicing_party: { domain: "examplepub-billing.example", + legal_name: "Example Publisher Billing LLC" }, + payment_terms: "net 30", invoice_period: month, + time_zone: "America/New_York" } correlation_id: "ep-q4-2026-commitment/business-daily-display" + +ext: + examplepub.example: { placement_code: "BD-ROS-300x250-Q4" } diff --git a/examples/manifest.json b/examples/manifest.json new file mode 100644 index 0000000..53b413e --- /dev/null +++ b/examples/manifest.json @@ -0,0 +1,123 @@ +{ + "generated_at": "2026-10-03T15:00:00Z", + "spec_version": "3.0", + "proposals": [ + { + "proposal_id": "examplepub-us-q4-2026-omni", + "version": 2, + "seller": { + "domain": "examplepub.example", + "legal_name": "Example Publisher US" + }, + "description": "Cross-channel Example Publisher US package spanning display, in-stream video and CTV. Business and financial audience skew, weekday-heavy delivery.\n", + "offer_window": { + "start": "2026-10-01T00:00:00Z", + "end": "2026-12-31T23:59:59Z" + }, + "full_url": "https://examplepub.example/openproposal/examplepub-us-q4-2026-omni", + "line_items": [ + { + "line_item_id": "streaming-audio", + "channel": "audio", + "environments": { + "available": [ + "web_mobile", + "app_mobile" + ] + }, + "formats": { + "included": [ + "audio/produced-30s" + ] + }, + "markets": { + "available": [ + "US", + "CA", + "GB" + ], + "included": [ + "US" + ], + "deltas": { + "CA": { + "delta_cpm": 1.5 + }, + "GB": { + "delta_cpm": 3.0 + } + }, + "max_select": 3 + }, + "pricing": [ + { + "cost_method": "CPM", + "list_rate": 18.0, + "currency": "USD" + } + ], + "commitment_bounds": { + "min_budget": 5000.0 + }, + "committed_metrics": [ + { + "metric": "completion_rate", + "value": 0.92, + "basis": "seller_reported" + } + ], + "transaction_mechanism": { + "available": [ + "preferred_deal" + ] + }, + "has_requires": false + }, + { + "line_item_id": "weather-app-ctv", + "channel": "ctv", + "environments": { + "available": [ + "ctv" + ] + }, + "properties": { + "available": [ + { + "id": "weather-app" + } + ] + }, + "formats": { + "included": [ + "ctv/instream-15s" + ] + }, + "pricing": [ + { + "cost_method": "CPM", + "list_rate": 45.0, + "currency": "USD" + } + ], + "commitment_bounds": { + "min_budget": 25000.0 + }, + "committed_metrics": [ + { + "metric": "completion_rate", + "value": 0.95, + "basis": "seller_reported" + } + ], + "transaction_mechanism": { + "available": [ + "private_auction" + ] + }, + "has_requires": false + } + ] + } + ] +} diff --git a/examples/proposal-issued.yaml b/examples/proposal-issued.yaml new file mode 100644 index 0000000..1396b90 --- /dev/null +++ b/examples/proposal-issued.yaml @@ -0,0 +1,112 @@ +# OpenProposal 3.0 — Example: Issued proposal, partly agreed (spec § 6.12) +# Illustrative only. Seller names and rates are fictional. The seller issues this +# proposal to Agency A from version 2 of the shared proposal in examples/proposal.yaml. +# One line item is agreed at version 1. The seller then revises the other, so the +# proposal is at version 2 and the entries still name version 1. A party verifies them +# over version 1, restricted to their line_item_ids (§ 4.1.2). +proposal_id: "examplepub-agencya-q4-2026-omni" +version: 2 +spec_version: "3.0" +seller: { domain: "examplepub.example", legal_name: "Example Publisher US", + registry_id: "reg:examplepub-digital" } +buyer: { domain: "agency-a.example", legal_name: "Agency A LLC" } +type: standard +issued_from: { proposal_id: "examplepub-us-q4-2026-omni", version: 2 } +status: published # derived: one line item remains offered (§ 4.3.3) +offer_window: { start: "2026-10-01T00:00:00Z", end: "2026-12-31T23:59:59Z" } + +description: > + Display and CTV line items for Agency A, issued from the Q4 omnichannel proposal. + +assent: + - { role: commitment, version: 1, + record_digest: "sha256:cdcd75ebd66938fcd872cfc1bf66d10a3244758d7810125639beabe0dcf53430", + line_item_ids: ["business-daily-display"], + signed_at: "2026-10-05T10:14:03Z" } + - { role: acceptance, version: 1, + record_digest: "sha256:cdcd75ebd66938fcd872cfc1bf66d10a3244758d7810125639beabe0dcf53430", + line_item_ids: ["business-daily-display"], + signed_at: "2026-10-05T10:31:47Z" } + +negotiation_history: + - { version: 1, actor: seller, action: published, paths: [], + at: "2026-10-04T09:00:00Z" } + - { version: 2, actor: seller, action: published, + paths: [{ line_item_id: "weather-app-ctv", pointer: "/materials_due" }], + at: "2026-10-06T16:20:00Z" } + +line_items: + - line_item_id: "business-daily-display" + status: agreed + channel: display + properties: + available: [{ id: "business-daily", domains: ["business-daily.example"] }] + included: ["business-daily"] + buyer_supplied: + suppression_allowed: true + suppression_list: { source: "advertiser_crm", size: 340000, match_method: "ramp_id" } + formats: + available: ["display/300x250", "display/970x250"] + included: ["display/300x250"] + deltas: { "display/970x250": { delta_cpm: 3.00 } } + selected: ["display/300x250", "display/970x250"] # the buyer's write + exclusions_accepted: + types: [domain, keyword] + max_entries: { domain: 5000, keyword: 500 } + enforcement: { domain: pre_serve, keyword: best_effort } + exclusions: # supplied by the buyer + domain: ["example-gambling.example"] + keyword: ["crypto"] + geo: + allowed: ["US"] + default: { include: ["US"] } + value: { include: ["US"], exclude: ["US-TX"] } # the buyer's write + frequency_cap: { count: { min: 1, max: 5, default: 3, value: 4 }, period: day, per: device } + time_zone: "America/New_York" + pricing: + # agreed_rate: (24.00 + 3.00) x (1 + 0.02) = 27.54 (§ 5.6) + - { cost_method: CPM, list_rate: 24.00, agreed_rate: 27.54, + price_valid_until: "2026-11-15T23:59:59Z", currency: USD } + commitment_bounds: { min_budget: 10000.00, max_budget: 500000.00 } + cancellation_policy: + available: [{ id: "notice-14d" }, { id: "notice-7d" }] + included: ["notice-14d"] + deltas: { "notice-7d": { delta_fraction: 0.02 } } + max_select: 1 + selected: ["notice-7d"] # the buyer's write + commitment: + proposal_id: "examplepub-agencya-q4-2026-omni" + version: 1 + basis: spend + amount: 80000.00 + currency: USD + flight: { start: "2026-10-12T00:00:00Z", end: "2026-12-21T23:59:59Z" } + advertisers: [{ domain: "brand-a.example" }] + derived_units: 2904865 # derived: 80000.00 / 27.54 × 1000 + idempotency_key: "agency-a-camp1-business-daily-display-8f42c1a9" + hold_status: { hold_available: true, hold_duration: "P7D", + hold_scope: line_item, state: none } + visibility: named + eligible_buyers: ["agency-a.example"] + transaction_mechanism: { available: ["programmatic_guaranteed"], + included: ["programmatic_guaranteed"], max_select: 1 } + + - line_item_id: "weather-app-ctv" + status: published + channel: ctv + properties: { available: [{ id: "weather-app" }], included: ["weather-app"] } + formats: + available: ["ctv/instream-15s"] + included: ["ctv/instream-15s"] + materials_due: { at: "2026-10-20T23:59:59Z" } # version 1 had "2026-10-13T23:59:59Z" + pricing: + - { cost_method: CPM, list_rate: 45.00, + price_valid_until: "2026-12-01T23:59:59Z", currency: USD } + commitment_bounds: { min_budget: 25000.00 } + hold_status: { hold_available: true, hold_duration: "P14D", # re-offered at version 2 + hold_scope: line_item, state: held, + hold_expires_at: "2026-10-20T00:00:00Z" } + visibility: named + eligible_buyers: ["agency-a.example"] + transaction_mechanism: { available: ["programmatic_guaranteed"], + included: ["programmatic_guaranteed"], max_select: 1 } diff --git a/examples/proposal.yaml b/examples/proposal.yaml index 9b51484..f480e2c 100644 --- a/examples/proposal.yaml +++ b/examples/proposal.yaml @@ -1,13 +1,24 @@ # OpenProposal 3.0 — Example: Proposal (spec § 6.1) # Illustrative only. Seller names and rates are fictional. proposal_id: "examplepub-us-q4-2026-omni" -version: 1 -seller_id: "reg:examplepub-digital" -seller_name: "Example Publisher US" +version: 2 +spec_version: "3.0" +seller: { domain: "examplepub.example", legal_name: "Example Publisher US", + registry_id: "reg:examplepub-digital" } # OPTIONAL: an attestation (§ 2.2) type: standard +proposal_template_id: "examplepub-q4-omni-template" # seller-side; buyers never resolve it status: published -valid_from: "2026-10-01T00:00:00Z" -valid_until: "2026-12-31T23:59:59Z" +offer_window: { start: "2026-10-01T00:00:00Z", end: "2026-12-31T23:59:59Z" } + +# The line_items of this proposal are shown in their own files (examples/line-item-*.yaml). +# Version 1 stays retrievable, unchanged (§ 4.1.3). +negotiation_history: + - { version: 1, actor: seller, action: published, paths: [], + at: "2026-09-22T09:00:00Z" } + - { version: 2, actor: seller, action: published, + paths: [{ line_item_id: "business-daily-display", pointer: "/creative_delivery" }, + { pointer: "/seasonality_notes" }], + at: "2026-10-03T14:12:40Z" } description: > Cross-channel Example Publisher US package spanning display, in-stream video @@ -19,3 +30,6 @@ specifications: > best_for: ["B2B", "financial services", "high-income reach"] not_suitable_for: ["youth", "endemic gaming"] seasonality_notes: "Earnings weeks carry materially higher engagement." + +ext: + examplepub.example: { package_code: "OMNI-Q4-2026" } diff --git a/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..ccad4b9 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,238 @@ 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. + ("`code`", "Returned when"), + ("`channel`", "Condition"), + ("`environments`", "Distribution channel", "Device types"), + # Other tables state rules as prose does (section 2.6). + ("OpenProposal concept", "Downstream standard"), + ("Layer", "Question", "Answered by", "Owner", "In the proposal object"), + ("Interaction", "Actor", "Effect; history `action`"), + ("Instant", "Ends", "Effect when passed"), + # 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 = { + "AssentEntry": "the assent entry", + "PropertyOption": "the property option", + "AudienceOption": "the audience option", + "BuyerSupplied": "the buyer-supplied audience data", + "CancellationOption": "the cancellation option", + "ExclusivityTerm": "the exclusivity term", + "MinCommitment": "the purchase-level threshold", + "CommittedMetric": "the committed metric tuple", + "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 = { + "`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", + "`exclusions_accepted`": "waiting: the accepted exclusion terms", + "`exclusions`": "waiting: the exclusion lists", + "`availability`": "waiting: the availability figure", + "`commitment_bounds`": "waiting: the commitment bounds", + "`creative_policy`": "waiting: the creative policy", + "`terms_ref`": "waiting: the binding document reference", } -# 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 +274,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..ee898ca --- /dev/null +++ b/scripts/check_examples.py @@ -0,0 +1,801 @@ +#!/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. + - Option names are unique, and included[], selected[], and deltas name options. + - selected[] or included[] fits max_select, and an empty set has a stated meaning + (section 3.1). + - A settable field holds its form, and each buyer value is within its bounds + (section 3.2). A line item declares geo or markets, not both (section 5.5). + - agreed_rate recomputes under section 5.6, on the committed entry only. + - The manifest is a strict path subset of the full objects (section 2.1), and lists + each public, published line item (section 2.3). + - seller and buyer on a proposal hold legal_name (section 4.1). + - Each section 4.1.1 removal path resolves to a table row. + - Each assent[] entry's record_digest recomputes under section 4.1.1, over the + line items its line_item_ids[] names. + +KNOWN holds findings that a later change removes. It only shrinks. Run with +--regen-manifest to rewrite examples/manifest.json from the full examples. + +Run: python3 scripts/check_examples.py +""" +import argparse +import hashlib +import json +import re +import sys +from decimal import ROUND_HALF_UP, Decimal +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", "ErrorObject"} +# 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 = { + # Line items: proposal.yaml shows them in their own files (section 6.1), until the + # change that lists the published line items inline in proposal.yaml. + ("proposal.yaml", "(root)", "line_items"), +} + + +# Selectable fields whose row gives an empty set a meaning (section 3.1). +EMPTY_ALLOWED = set() +# The cost methods delta_cpm applies to (section 3.1). +IMPRESSION_METHODS = set() + + +def _committed_entry(item): + """The pricing[] entry the line item's commitment is priced against, if any.""" + entries = item.get("pricing") or [] + if not item.get("commitment"): + return None + return entries[0] if len(entries) == 1 else None + + +# Each `REQUIRED when ` wording, as (object, line item, proposal) -> bool. +CONDITIONS = { + "`type` is `custom`": lambda o, i, p: o.get("type") == "custom", + "`type` is `custom` or `assent[]` is present": + lambda o, i, p: o.get("type") == "custom" or "assent" in o, + "the current `version` is frozen (§ 4.1.3)": lambda o, i, p: o.get("status") != "draft", + "`requested_value` is present": lambda o, i, p: "requested_value" in o, + "the entry's `action` is `requested`": + lambda o, i, p: any(e.get("action") == "requested" and any(x is o for x in e.get("paths", [])) + for e in (p or {}).get("negotiation_history", [])), + "`invoice_period` is `cycle`": lambda o, i, p: o.get("invoice_period") == "cycle", + "`terms_acceptance_required` is `true`": + lambda o, i, p: i.get("terms_acceptance_required") is True, + "`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, + "this is the committed entry": lambda o, i, p: _committed_entry(i) is o, + "`daypart` or `frequency_cap` is present": + lambda o, i, p: "daypart" in o or "frequency_cap" in o, +} + +BASE = {"String", "Integer", "Number", "Boolean", "Enum", "Object"} +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") +# A GeoCode (section 5.5): ISO 3166-1 alpha-2, ISO 3166-2, or an extension term. +GEO_CODE = re.compile(rf"(?:[A-Z]{{2}}(?:-[A-Z0-9]{{1,3}})?|{TERM_DOMAIN}{TERM_PART})\Z") +CLOCK_TIME = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d\Z|24:00\Z") +SIDE = ("buyer", "seller") +PROPOSAL_STATUS = ("draft", "published", "under_review", "agreed", "withdrawn", "expired") +CALENDAR_UNIT = ("hour", "day", "week", "month") +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)), + "GeoCode": (lambda v: isinstance(v, str) and GEO_CODE.match(v)), + "ClockTime": (lambda v: isinstance(v, str) and CLOCK_TIME.match(v)), + "DayOfWeek": (lambda v: v in ("monday", "tuesday", "wednesday", "thursday", "friday", + "saturday", "sunday")), + "MediaChannel": (lambda v: isinstance(v, str) and TERM.match(v)), + "String": (lambda v: isinstance(v, str)), + "Url": (lambda v: isinstance(v, str) and v.startswith(("http://", "https://"))), + "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), + "ProposalStatus": (lambda v: v in PROPOSAL_STATUS), + "LineItemStatus": (lambda v: v in PROPOSAL_STATUS or v == "sold_out"), + "ApprovalStatus": (lambda v: v in ("PENDING", "APPROVED", "REJECTED")), + "EntryRef": (lambda v: isinstance(v, str) and re.fullmatch(r"[^/@]+/[^/@]+/[^/@]+@[^/@]+", v)), + "FieldPointer": (lambda v: isinstance(v, str) and (v == "" or v.startswith("/"))), + "Ext": (lambda v: isinstance(v, dict) and all( + isinstance(k, str) and DOMAIN.match(k) and isinstance(item, dict) + for k, item in v.items() + )), +} + + +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"(? value["max_select"]: + self.err(file, path, "max_select", "more options in effect than max_select") + if not in_effect and field not in EMPTY_ALLOWED: + self.err(file, path, "empty", f"{field} gives an empty set no meaning") + + def covers(self, field, outer, inner): + """Whether an entry of a settable field covers another (sections 3.2, 5.5).""" + if outer == inner: + return True + if field == "geo": + return str(inner).startswith(f"{outer}-") + if field == "device": + return outer == 1 and inner in (4, 5) + if field == "daypart": + return (set(inner.get("days", [])) <= set(outer.get("days", [])) + and outer.get("start", "") <= inner.get("start", "") + and inner.get("before", "") <= outer.get("before", "")) + return False + + def check_narrowing(self, file, path, value, item_type, allowed): + if not isinstance(value, dict) or not value.keys() <= {"include", "exclude"}: + self.err(file, path, "narrowing", "a Narrowing holds include[] and exclude[]") + return + if "include" not in value: + self.err(file, path, "include", "REQUIRED key 'include' is missing") + field = path.split(".")[-2] + include, exclude = value.get("include", []), value.get("exclude", []) + for key, within, entries in (("allowed", allowed, include), ("include", include, exclude)): + for entry in entries: + self.check_type(file, path, entry, item_type) + if not any(self.covers(field, outer, entry) for outer in within): + self.err(file, path, "covered", f"{entry!r} is not covered by {key}") + + def check_settable(self, file, path, value, item_type): + """Section 3.2: the envelope form, allowed[] with a default and the buyer's value.""" + if not isinstance(value, dict) or not value.keys() <= {"allowed", "default", "value"}: + self.err(file, path, "settable", "a Settable holds allowed[], default, and value") + return + for key in ("allowed", "default"): + if key not in value: + self.err(file, path, key, f"REQUIRED key {key!r} is missing") + allowed = value.get("allowed", []) + for i, entry in enumerate(allowed): + self.check_type(file, f"{path}.allowed[{i}]", entry, item_type) + for key in ("default", "value"): + if key in value: + self.check_narrowing(file, f"{path}.{key}", value[key], item_type, allowed) + + def check_settable_range(self, file, path, value, item_type): + """Section 3.2: the range form, the keys of Range with a default and the buyer's value.""" + if not isinstance(value, dict) or not value.keys() <= {"min", "max", "default", "value"}: + self.err(file, path, "settable", "a SettableRange holds min, max, default, and value") + return + if "min" not in value and "max" not in value: + self.err(file, path, "bound", "a SettableRange holds at least one bound") + if "default" not in value: + self.err(file, path, "default", "REQUIRED key 'default' is missing") + for key in ("min", "max", "default", "value"): + if key in value: + self.check_type(file, f"{path}.{key}", value[key], item_type) + if "min" in value and "max" in value and value["min"] > value["max"]: + self.err(file, path, "bound", "min is greater than max") + for key in ("default", "value"): + if key in value and ((("min" in value) and value[key] < value["min"]) + or (("max" in value) and value[key] > value["max"])): + self.err(file, f"{path}.{key}", "bounds", f"{key} is outside the bounds") + + def check_agreed_rate(self, file, path, item): + """Recompute agreed_rate on the committed entry (section 5.6).""" + committed = _committed_entry(item) + for entry in item.get("pricing") or []: + if entry is not committed and "agreed_rate" in entry: + self.err(file, path, "agreed_rate", "agreed_rate on an uncommitted entry") + if committed is None or "agreed_rate" not in committed: + return + decimal = lambda n: Decimal(str(n)) + cpm, factor = Decimal(0), Decimal(1) + for value in item.values(): + if not isinstance(value, dict) or "available" not in value: + continue + included = value.get("included", []) + names = [n for n in value.get("selected", included) if n not in included] + for name in names: + delta = (value.get("deltas") or {}).get(name, {}) + if committed.get("cost_method") in IMPRESSION_METHODS: + cpm += decimal(delta.get("delta_cpm", 0)) + elif set(delta) == {"delta_cpm"}: + self.err(file, path, name, "an option priced only by delta_cpm on this entry") + factor *= 1 + decimal(delta.get("delta_fraction", 0)) + rate = (decimal(committed["list_rate"]) + cpm) * factor + rate = rate.quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP) + if rate != decimal(committed["agreed_rate"]): + self.err(file, path, "agreed_rate", f"agreed_rate recomputes to {rate}") + + def check_fields(self, file, path, doc, fields): + known = {bare(k): r for k, r in fields.items() if "[]." not in k} + if "line_item_id" in doc: + self.item = doc + else: + self.proposal = doc + self.item = {} + for key in ("seller", "buyer"): + if key in doc and "legal_name" not in doc[key]: + self.err(file, key, "legal_name", "legal_name is REQUIRED here") + for key in doc: + if key not in known: + self.err(file, path or "(root)", key, f"key {key!r} is not in a field table") + for key, row in known.items(): + if key not in doc and self.required(file, path, row["required"], doc): + self.err(file, path or "(root)", key, f"REQUIRED field {key!r} is missing") + if key in doc: + sub = f"{path}.{key}" if path else key + if key == "pricing" and isinstance(doc[key], list): + self.check_pricing(file, sub, doc[key], fields) + elif key == "line_items": + continue + else: + self.check_value(file, sub, doc[key], row) + if "line_item_id" in doc: + self.check_agreed_rate(file, path or "(root)", doc) + if "geo" in doc and "markets" in doc: + self.err(file, path or "(root)", "markets", "a line item declares geo or markets") + + def check_pricing(self, file, path, entries, fields): + rows = {bare(k[len("pricing[]."):]): r for k, r in fields.items() if k.startswith("pricing[].")} + for i, entry in enumerate(entries): + sub = f"{path}[{i}]" + for key in entry: + if key not in rows: + self.err(file, sub, key, f"key {key!r} is not in the pricing[] rows") + for key, row in rows.items(): + if key not in entry and self.required(file, sub, row["required"], entry): + self.err(file, sub, key, f"REQUIRED key {key!r} is missing") + if key in entry: + self.check_value(file, f"{sub}.{key}", entry[key], row) + + def check_removal_paths(self): + for path in self.spec.removal: + head_path = path.split(",")[0] + parts = [p for p in head_path.lstrip("$.").split(".") if p != "*"] + parts = [re.sub(r"\[\*\]$", "", p) for p in parts] + fields = self.spec.proposal + if parts[0] == "line_items": + fields, parts = self.spec.line_item, parts[1:] + ok = parts[0] in {bare(k) for k in fields} + if ok and len(parts) > 1 and parts[0] == "pricing": + ok = f"pricing[].{parts[1]}" in fields + if not ok: + self.err("spec", "removal path", path, f"{path} resolves to no row") + + +# Digest -------------------------------------------------------------------- + + +def jcs_number(n): + if isinstance(n, bool): + return "true" if n else "false" + if isinstance(n, int): + return str(n) + if n == int(n) and abs(n) < 1e21: + return str(int(n)) + return repr(n) + + +def jcs(value): + """RFC 8785 serialization, for the values examples use.""" + if value is None: + return "null" + if isinstance(value, (bool, int, float)): + return jcs_number(value) + if isinstance(value, str): + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + if isinstance(value, list): + return "[" + ",".join(jcs(v) for v in value) + "]" + keys = sorted(value, key=lambda k: k.encode("utf-16-be")) + return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + jcs(value[k]) for k in keys) + "}" + + +def remove_path(node, parts, keep=()): + if not parts: + return + head, rest = parts[0], parts[1:] + if head.endswith("[*]"): + for item in (node.get(head[:-3]) or []): + remove_path(item, rest, keep) + return + if head == "*": + if isinstance(node, dict): + for key in list(node): + if key not in keep: + del node[key] + return + if not rest: + node.pop(head, None) + elif isinstance(node.get(head), dict): + remove_path(node[head], rest, keep) + + +def record_digest(doc, removal, line_item_ids): + """Section 4.1.1: the digest of the binding record for the named line items.""" + copy = json.loads(json.dumps(doc)) + copy["line_items"] = [i for i in copy.get("line_items", []) if i["line_item_id"] in line_item_ids] + for path in removal: + head, _, tail = path.partition(", except ") + keep = re.split(r",\s*|\s+and\s+", tail) if tail else () + remove_path(copy, head[2:].split("."), keep) + return "sha256:" + hashlib.sha256(jcs(copy).encode("utf-8")).hexdigest() + + +# Summary ------------------------------------------------------------------- + + +def project(doc, paths): + """Return the strict path subset of doc for the listed paths.""" + tree = {} + for path in paths: + node = tree + for part in path.split("."): + node = node.setdefault(part, {}) + + def walk(value, spec): + if not spec: + return value + if isinstance(value, list): + return [walk(v, spec) for v in value] + if not isinstance(value, dict): + return value + res = {} + for key, sub in spec.items(): + if bare(key) in value: + res[bare(key)] = walk(value[bare(key)], sub) + return res + + return walk(doc, tree) + + +def full_examples(): + items = {} + for path in sorted(EXAMPLES.glob("line-item-*.yaml")): + doc = load_yaml(path) + items[doc["line_item_id"]] = doc + return items + + +def line_item_summary(item, spec): + out = project(item, spec.summary_paths) + out["has_requires"] = "requires" in item + return out + + +def proposal_summary(proposal, spec): + return project(proposal, spec.proposal_paths) + + +def build_manifest(spec): + """Section 2.3: the proposal's public, published line items, as summaries.""" + proposal = load_yaml(EXAMPLES / "proposal.yaml") + entry = proposal_summary(proposal, spec) + entry["full_url"] = f"https://{proposal['seller']['domain']}/openproposal/{proposal['proposal_id']}" + entry["line_items"] = [ + line_item_summary(item, spec) for item in full_examples().values() + if item.get("visibility") == "public" and item.get("status") == "published" + ] + return { + "generated_at": "2026-10-03T15:00:00Z", + "spec_version": "3.0", + "proposals": [entry], + } + + +def check_manifest(checker, spec): + path = EXAMPLES / "manifest.json" + manifest = load_json(path) + checker.check_object("manifest.json", "(root)", manifest, "ProposalManifest") + expected = build_manifest(spec) + expected["generated_at"] = manifest.get("generated_at") + if manifest != expected: + checker.err("manifest.json", "(root)", "subset", + "the manifest is not the path subset of the full examples; " + "run --regen-manifest") + + +# Main ---------------------------------------------------------------------- + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--regen-manifest", action="store_true") + args = ap.parse_args() + spec = Spec() + if args.regen_manifest: + manifest = build_manifest(spec) + (EXAMPLES / "manifest.json").write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n") + print("examples/manifest.json regenerated") + return 0 + + checker = Checker(spec) + for path in sorted(EXAMPLES.glob("*.yaml")): + try: + doc = load_yaml(path) + except yaml.YAMLError as e: + checker.errors.append(f"{path.name}: does not parse strictly: {e}") + continue + checker.proposal = None + if "line_item_id" in doc: + checker.check_fields(path.name, "", doc, spec.line_item) + else: + checker.check_fields(path.name, "", doc, spec.proposal) + for i, item in enumerate(doc.get("line_items", [])): + checker.check_fields(path.name, f"line_items[{i}]", item, spec.line_item) + try: + check_manifest(checker, spec) + except (ValueError, KeyError) as e: + checker.errors.append(f"manifest.json: {e}") + checker.check_removal_paths() + + digests = {} + for path in sorted(EXAMPLES.glob("*.yaml")): + doc = load_yaml(path) + for entry in doc.get("assent", []): + digest = record_digest(doc, spec.removal, entry["line_item_ids"]) + digests[path.name] = digest + if entry["record_digest"] != digest: + checker.errors.append(f"{path.name}: record_digest recomputes to {digest}") + + stale = KNOWN - checker.used_known + for item in sorted(stale): + checker.errors.append(f"KNOWN no longer needed, remove it: {item}") + + if checker.errors: + print("Examples disagree with the specification:\n") + for e in checker.errors: + print(f" - {e}") + return 1 + print("Examples match the tables.") + for name, digest in digests.items(): + print(f" record_digest of {name}: {digest}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 1dfacef..1ebb0ff 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,6 +5,172 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] +- § 1.1, § 2.1, § 2.2, § 2.6, § 3, § 3.1 to § 3.4, § 4.1, § 4.1.1, § 4.1.3, § 4.3.1, + § 4.3.2, § 5.1.1, § 5.2, § 5.2.1, § 5.4, § 5.5, § 5.6, § 5.6.1, § 5.6.3, § 5.7, § 5.9, + § 6, appendices, examples, checker — what the buyer writes, and how the rate is computed. + `selected[]` is the complete set of options in effect and replaces `included[]`; `value` + is the buyer's key on a `settable` field, beside a required `default`. An envelope's + `value` is `{ include[], exclude[] }` and only narrows `allowed[]`. `frequency_cap.count` + is the range form. The bare-value fallbacks are gone. An object option has a required + `id`, unique in the field. Options combine freely, and `measurement_source` and + `reporting` say what nothing in effect means. `agreed_rate` has one formula, sits only on + the committed entry, and rounds once. `gross_rate` becomes `list_rate`, and `floor` + becomes `min_rate`, a hint that grants nothing + ([#133](https://github.com/IABTechLab/OpenProposal/issues/133)). `delta_pct` becomes + `delta_fraction`, a fraction, so 2 percent is `0.02`. `list_rate` and `commitment_bounds` + are `requestable`, and a granted rate is the `list_rate` of the next version. `commitment` + is `mixed`. `transaction_mechanism` declares `max_select: 1`. § 3.3 states the + availability read, and § 3.4 names `BuyerWrites` and `LineItemWrites`. `geo` holds + `GeoCode` entries as a `settable` field, and the selectable form becomes `markets`. + `device` holds AdCOM codes, a `daypart` window is a `ClockWindow` that ends `before` a + minute, and `supported_formats` is removed. Breaking + ([#22](https://github.com/IABTechLab/OpenProposal/issues/22), + [#30](https://github.com/IABTechLab/OpenProposal/issues/30), + [#58](https://github.com/IABTechLab/OpenProposal/issues/58), + [#14](https://github.com/IABTechLab/OpenProposal/issues/14), + [#111](https://github.com/IABTechLab/OpenProposal/issues/111)). +- § 1.2, § 2.2, § 2.3, § 2.4, § 3, § 4.1, § 4.1.1, § 4.1.2, § 4.1.3, § 4.3, § 5.1, § 5.4, + § 5.6, § 5.6.1, § 5.6.3, § 5.8, § 5.9, § 6, § 7, appendices, examples, checkers — who + moves each status, and when an offer goes stale. The `seller-set` gate reads the `status` + of the object that carries the field. § 4.1.1 and § 4.1.2 define the binding record, + `record_digest`, and `assent[]` entries, which replace the `assent` object. § 4.1.3 freezes + a version's offered terms, and § 4.3 states who moves each status. A resolved `commitment` + is an offer until the seller accepts it. A proposal with no `buyer` is shared, and one with + a `buyer` is issued; `issued_from`, a `VersionRef`, is added, and a `custom` proposal is + issued from the start. An ask is stale only when the binding record of the line items it + names differs between its `version` and the current one. History actions become + `published`, `requested`, `granted`, `countered`, `declined`, and `withdrawn`, and a + `commit` and its acceptance are in `assent[]` only. `accept` answers only a `commit`; a + `request` is answered by `grant`, `counter`, or `decline`, and is identified by the value + it asks. `HistoryEntry`, `HistoryPath`, `RequestedValue`, `ProposalStatus`, + `LineItemStatus`, and `ApprovalStatus` are named types. The status table is exhaustive, a + firm entry is defined per `pricing[]` entry, and the allowed grounds for declining one are + listed once. A `draft` line item leaves the proposal `published`. `linked_execution` takes + only `all`, which covers every `line_item_id` the version carries, and `offer_window` does + not hold before `start`. `agreed` is stated once. Breaking + ([#60](https://github.com/IABTechLab/OpenProposal/issues/60), + [#64](https://github.com/IABTechLab/OpenProposal/issues/64), + [#65](https://github.com/IABTechLab/OpenProposal/issues/65), + [#67](https://github.com/IABTechLab/OpenProposal/issues/67), + [#63](https://github.com/IABTechLab/OpenProposal/issues/63), + [#68](https://github.com/IABTechLab/OpenProposal/issues/68), + [#70](https://github.com/IABTechLab/OpenProposal/issues/70), + [#26](https://github.com/IABTechLab/OpenProposal/issues/26), + [#24](https://github.com/IABTechLab/OpenProposal/issues/24)). +- § 1.2, § 2.1 to § 2.5, § 4.1, § 5.6.1, § 5.8, § 5.9, § 5.10, § 7, § 8, appendices, + examples, docs, checker — who the parties are, and what each identity layer proves. + `seller_id` and `seller_name` become `seller`, a `Party` whose `legal_name` is + REQUIRED, and the registry reference becomes `seller.registry_id`. `buyer` and + `commitment.advertisers[]` are added. § 1.2 states who owns request authentication, + authorization, and legal assent. § 5.8 defines each `visibility` value, which is + REQUIRED, and separates who may read from who may commit. Eligibility is part of + resolving a `commitment`; a failure returns `not_eligible`, and an unreadable line + item returns `not_found`. `required_credentials[]` entries become + `{ credential_type, issuer }`: `scheme`, its token list, and `id_required` are + removed, and so are the `dsp_seat` and `custom` types. `eligible_bidders[]` splits + into `eligible_buyers[]` and `eligible_seats[]`. `dsp_id` and `dsp_seat_id` become + `buyer_requirements.seats`, a `SeatRef`; `seat_required` becomes `seats_expected`, + `dsp_allowed[]` becomes `allowed_dsps[]`, and `seat_owner_entity` becomes + `seat_owner`. `billing.supplier_id` and `billing.billing_supplier_id` become + `billing.delivering_party`, and `billing` adds `invoice_period`, `cycle_length`, and + `time_zone`. `agreement_ref` becomes `agreement`, with `agreement_type` and `term`, + and its `parties[]` holds `Party` values. The manifest drops `seller_id`, filters on + `status` as well as `visibility`, and needs at least one of `ETag` and + `Last-Modified`; § 8 asks whether it needs both. A buyer discards a manifest entry + whose `seller.domain` differs from the requested domain, and `full_url` returns only + what the requester may read. A full fetch leaves out line items the requester may + not read, and `ErrorObject.version` applies to a proposal the requester may read. + `proposal_id` is unique within `seller.domain`, and a seller's own `ext` key is its + `seller.domain`. `agreement_ref.valid_from` and `valid_until` become + `agreement.term`, `agreement` adds `document`, and `external_references[].role` is a + `VocabularyTerm`. Breaking + ([#61](https://github.com/IABTechLab/OpenProposal/issues/61), + [#62](https://github.com/IABTechLab/OpenProposal/issues/62), + [#132](https://github.com/IABTechLab/OpenProposal/issues/132), + [#126](https://github.com/IABTechLab/OpenProposal/issues/126), + [#57](https://github.com/IABTechLab/OpenProposal/issues/57), + [#29](https://github.com/IABTechLab/OpenProposal/issues/29), + [#27](https://github.com/IABTechLab/OpenProposal/issues/27)). +- § 2, § 2.2, § 2.4, § 2.5, § 2.7, § 4.1, § 5.1, § 5.5, § 5.6.1, § 5.6.3, § 8, + appendices, examples, docs, checker — errors, extensions, and forward + compatibility. A rejection returns `errors[]`, one or more `ErrorObject`s with + `code`, `detail`, `pointer`, `version`, and `retry`. The core codes include + `not_eligible` and `record_mismatch`, and anyone can add a code under a domain + they control. An `ErrorObject` is open, and `validation` applies when no other code + does. `idempotency_conflict` tests the body, and an error with `retry: same` is not + a stored result. § 5.5, § 5.6.1, and § 5.6.3 name the code each rejection returns. + The proposal and each line item MAY carry `ext`, an `Ext` keyed by owner domain. A + receiver ignores an entry it doesn't understand and keeps it unchanged, and `ext` + is frozen at `agreed`. A key outside `ext` that a key table doesn't list is invalid. + `spec_version` is a REQUIRED proposal field, and § 8 asks how a receiver reads a + later release. § 2.7 adds informative security considerations. The `requires` + cycle ban is removed. Breaking + ([#130](https://github.com/IABTechLab/OpenProposal/issues/130), + [#131](https://github.com/IABTechLab/OpenProposal/issues/131), + [#33](https://github.com/IABTechLab/OpenProposal/issues/33)). +- § 2, § 3, § 4, § 5, § 7, § 8, appendices, examples, docs, checker — shared types and + authoring rules. § 2.2 gives each repeated concept one type, with one rule for an + unknown vocabulary term. § 2.6 states the table, presence, type, key, and value + rules. Every field and key row has a Type and a Required column. § 3 adds the + `lifecycle`, `recorded`, `asserted`, and `mixed` markers, and the proposal lists its + `line_items[]`. The Summary Representation is a strict path subset, and the manifest + has a key table. `catalog_ref` is removed. These names change: `valid_from` and + `valid_until` become `offer_window`, and `flight_start` and `flight_end` become + `commitment.flight`. `brief_ref` and `format_ref` become `brief_id` and `format_id`, + and `url_or_tag` becomes `url` or `markup`. `fields_changed[]`, `min_commitment[]`, + and `metrics_provided[]` become `paths[]`, `min_commitments[]`, and + `provided_metrics[]`. `frequency_cap.max` and `frequency_cap.basis` become `count` + and `per`, and the selectable `pricing` map becomes `deltas`. `materials_due` holds + `at` or `before_start`. Owned value lists are lower snake case, every term compares + byte for byte, and `cost_method` writes `FLAT`. Headings are numbered, and normative + text cites no issue. `scripts/check_examples.py` checks the examples against the + tables. Breaking + ([#121](https://github.com/IABTechLab/OpenProposal/issues/121), + [#31](https://github.com/IABTechLab/OpenProposal/issues/31), + [#33](https://github.com/IABTechLab/OpenProposal/issues/33), + [#2](https://github.com/IABTechLab/OpenProposal/issues/2), + [#3](https://github.com/IABTechLab/OpenProposal/issues/3)). +- § 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)). +- Define the boundary between creative inspection and production notifications + in section 5.4, 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 + ([#23](https://github.com/IABTechLab/OpenProposal/issues/23)). +- § 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)). +- § 5.5 — Buyer-supplied block lists: `exclusions_accepted` (what the seller + can enforce) and `exclusions` (the buyer's lists) + ([#25](https://github.com/IABTechLab/OpenProposal/issues/25)). +- § 4.1, § 5.6.1 — commitments carry `proposal_id` and `version`; a new + attempt on a stale version, or past `valid_until` or `price_valid_until`, is + rejected, and a replay returns the stored result. Breaking + ([#24](https://github.com/IABTechLab/OpenProposal/issues/24)). +- § 5.8, § 5.10, § 7 — `terms_ref` for the exact terms accepted at assent, + `agreement_ref` for the agreement a line item is priced under, and `io` and + `agreement` roles on `external_references[]`. Breaking + ([#27](https://github.com/IABTechLab/OpenProposal/issues/27)). +- § 4.1, § 7 — `proposal_template_id` links an issued proposal to the + seller's template; each issued proposal has its own `status`, `version` and + `negotiation_history[]` + ([#26](https://github.com/IABTechLab/OpenProposal/issues/26)). +- § 5.5 — `geo` may be `selectable`, with per-market price deltas + ([#14](https://github.com/IABTechLab/OpenProposal/issues/14)). +- § 5.5 — `time_zone` on the line item is the clock for `daypart` and + `frequency_cap.period`: an IANA time zone name or `viewer_local`, REQUIRED + with either. A `daypart` window is `{ days[], start, end }` in civil time, + with `end` exclusive and later than `start`; a window that crosses midnight + is two windows. `frequency_cap.period` is a calendar period in `time_zone`. + Breaking ([#111](https://github.com/IABTechLab/OpenProposal/issues/111)). +- § 5.6 — `pricing[].floor` is the offer floor, not the downstream auction + floor. Editorial clarification ([#16](https://github.com/IABTechLab/OpenProposal/issues/16)). + ## [3.0-draft-1] — 2026-09-22 Initial draft published for public comment. Ported from the working-group diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 6940958..ef5ee39 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -36,7 +36,7 @@ offer and the states and interactions that act on it. Two shapes of the object exist: - **Standard proposal** — published unilaterally by the seller's agent. No brief required. -- **Custom proposal** — built in response to a buyer's agent brief, referenced via `brief_ref`. +- **Custom proposal** — built in response to a buyer's agent brief, referenced via `brief_id`. ### 1.1 Downstream standards @@ -50,65 +50,432 @@ lands in the one its `transaction_mechanism` maps to: | PMP deal | Deals API + OpenRTB 2.6 · Deal | | Open market | OpenRTB 2.6 · open auction, no deal | +A `GeoCode` (§ 5.5) converts for the +[OpenRTB 2.6 `Geo` object](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/1debba8001f40eb14597102dec3205afddf811d4/2.6.md#objectgeo): +`country` takes the ISO 3166-1 alpha-3 code of the country, and `region` takes the +part of an ISO 3166-2 code after the hyphen. + +### 1.2 Relationship to other AAMP artifacts + +**Identity.** Three questions about a party stay separate, for the buyer and the +seller alike. An answer to one is not an answer to another. + +| Layer | Question | Answered by | Owner | In the proposal object | +| :--- | :--- | :--- | :--- | :--- | +| Request authentication | Which origin sent this request? | An [HTTP Message Signature](https://www.rfc-editor.org/rfc/rfc9421), with the key in that origin's [Web Bot Auth](https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/) directory | The AAMP agent identity and signing profile (provisional name) | Nothing. The object never carries or re-proves it | +| Authorization | What may this party, or its agent, do? | The seller's eligibility rules; a mandate the principal publishes or signs | This specification for the rules (§ 5.8); the AAMP authority document (provisional name) for mandates | The rules only, never the proof | +| Legal assent | Which legal entity agreed to which terms? | The `assent[]` entries for the binding version, with both parties named in the proposal | This specification for the parties and the record (§ 4.1); the profile for any signature over it | Parties, `assent[]` | + +A request signature proves an `https` origin. It does not prove which party that +origin speaks for: a hosted agent's origin is the host's, and a mandate the party +publishes or signs connects them. What authenticates a seller's response is the +profile's to state. Reading a public file (§ 2.3) needs none of this. + --- ## 2. Object shape ``` Proposal → the merchandising pitch, identity, lifecycle state and assent -Line Items[] → fully self-contained buyable units, each in exactly one channel +line_items[] → buyable units, each in exactly one channel ``` -**No-inheritance rule.** Each line item MUST be fully self-contained. A field on -a line item MUST NOT inherit its value from the proposal or from a sibling line -item. There is no "overridable unless restated" semantics anywhere in this -specification. +**No-inheritance rule.** A field on a line item MUST NOT inherit its value from +the proposal or from a sibling line item. There is no "overridable unless +restated" semantics anywhere in this specification. Every term of a line item is +on the line item. The proposal supplies only identity, `spec_version`, the offer +window, `version`, and linked execution. **Subsettable proposals.** A buyer MAY select and commit a subset of a -proposal's line items, unless the proposal 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 -Every line item has two representations. +Every proposal and every line item has two representations. + +**Full Representation:** every field the object holds, with a proposal's line items +in their Full Representation. A full fetch MUST return it, except for line items the +requester may not read (§ 5.8). + +**Summary Representation:** what a buying agent reads when screening many +sellers' shelves. A manifest entry holds it (§ 2.3), and a response MAY +carry it. A summary is a strict path subset of the full object: a listed path has +the type and value it has in the Full Representation, it appears when the full form +has it, and no other path appears. A summary's own table adds keys beside these +paths: `has_requires`, which the full object doesn't hold, and the `line_items[]` of +`ProposalSummary`, whose entries are line item summaries. + +`LineItemSummary` lists these paths of a line item (§ 5): + +```text +line_item_id +channel +environments.available[] +content_detail +properties.available[].id +properties.available[].name +properties.available[].domains[] +audiences.available[].id +audiences.available[].name +formats.included[] +geo +markets +pricing[].cost_method +pricing[].list_rate +pricing[].currency +min_spend +commitment_bounds +addressable_scale +committed_metrics[] +required_credentials[] +buyer_requirements.allowed_dsps[] +transaction_mechanism.available[] +``` -**Summary Representation** — what a buying agent reads when screening many -sellers' shelves. A catalog response MAY return it. It MUST contain, per line -item: +**`LineItemSummary`** adds this key: -`line_item_id` · `channel` · `content_detail` · property names · -`formats.included[]` · `pricing.cost_method` · `pricing.gross_rate` · -`min_spend` · `addressable_scale` · `committed_metrics[]` · -`transaction_mechanism` +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `has_requires` | Boolean | REQUIRED | Derived: `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 | + +`ProposalSummary` lists these paths of a proposal (§ 4): + +```text +proposal_id +version +seller.domain +seller.legal_name +description +offer_window +linked_execution +``` -plus, from the parent proposal: `proposal_id` · `seller_name` · `description` · -`valid_from` · `valid_until`. +**`ProposalSummary`** adds this key: -`catalog_ref` values MUST remain unresolved in a summary. +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `line_items[]` | Array[LineItemSummary] | REQUIRED | The full form's line items, each as a summary. In a manifest entry, only those § 2.3 lists | -**Full Representation** — every field, with all `catalog_ref` values resolved. -A full fetch MUST return it. +### 2.2 Shared types -### 2.2 Catalog references +One concept has one type, and every field that holds the concept uses it. A field +that holds a shared type states what the value means there, not its form. Its row +MAY narrow the type for that field, by requiring a key the type leaves OPTIONAL or by +limiting the values or count the type allows, and says so. A row never loosens a +shared type or changes its form. -Any field whose value is drawn from a seller-maintained catalog MAY carry a -reference in place of an inline value: +| 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 | +| `ProposalStatus` | `draft` \| `published` \| `under_review` \| `agreed` \| `withdrawn` \| `expired` | A proposal's `status`, computed by § 4.3.3 | +| `LineItemStatus` | A `ProposalStatus`, or `sold_out` | A line item's `status`, moved as § 4.3.2 states | +| `ApprovalStatus` | `PENDING` \| `APPROVED` \| `REJECTED` | An approval outcome, in the spelling of OpenDirect 2.1's ChangeRequest `status` | +| `Measurer` | A `Side`, or a `RegistrableDomain` | Who counts or measures. A seller writes `seller`, never its own domain. Two values name one party only when they are equal byte for byte. A `qualifier` beside a value names a product or method of that party and is not compared | +| `EntryRef` | String: `//@`, with no `/` or `@` inside a part | A named entry at one exact version, whose content does not change. `` is a `Domain`. It carries no digest | +| `VocabularyTerm` | String: a bare core term, or `:` with `` a `Domain` the definer controls. A term is ASCII letters, digits, `_`, and `-` | See below | +| `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 `delta_fraction` (§ 3.1) | +| `Instant` | String: an RFC 3339 `date-time` in whole seconds, ending in `Z` | A point in time. One written form, so one instant has one digest input | +| `Duration` | String: the ISO 8601 grammar of RFC 3339 Appendix A without its week form (`P14D`, not `P2W`) | A length of time. A comparison names its anchor. A length within the playback of an ad or a placement, such as a play, view, read, loop, slot, or refresh, is instead an Integer whose key ends `_s` or `_ms`, except a key imported from another standard, which keeps its spelling (§ 2.6) | +| `IanaZone` | String: a name in the [IANA time zone database](https://www.iana.org/time-zones), such as `America/New_York` or `UTC` | A time zone | +| `ZoneName` | An `IanaZone`, or `viewer_local` | `viewer_local` is where the ad renders | +| `Url` | String: an absolute `http` or `https` URL ([RFC 3986](https://www.rfc-editor.org/rfc/rfc3986)) | A location a client can fetch | +| `FieldPointer` | String: a JSON Pointer ([RFC 6901](https://www.rfc-editor.org/rfc/rfc6901)) | A location in the object this specification defines | +| `Ext` | `Map[Domain, Object]` | The extension container. § 2.5 states its rules | + +**`VocabularyTerm` rules.** A field that takes a `VocabularyTerm` lists its core +terms. Anyone MAY define an extension term. Terms compare byte for byte. A +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 | -``` -catalog_ref: "//@" -example: "examplepub/audiences/smb-decision-makers@3" +**`VersionRef`** locates an offer at a version. It carries no digest. A `proposal_id` +is unique only within a `seller.domain` (§ 4.1): a `VersionRef` inside a proposal +resolves against that proposal's `seller`, and a system that holds one outside a +proposal keeps the `seller.domain` beside it. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `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. An absent or unresolvable `registry_id` MUST NOT make a document +malformed, and a consumer MUST ignore a resolved entry whose verified domain is +not `domain`. The Agent Registry defines its reference format and how it exposes the +verified domain. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `domain` | RegistrableDomain | REQUIRED | The party's domain | +| `legal_name` | String | OPTIONAL | The party's legal name. A `registry_id` entry that applies can attest it; the value alone attests nothing | +| `registry_id` | String | OPTIONAL | An IAB Tech Lab Agent Registry reference for the entity controlling `domain` | + +**`SeatRef`** names a buyer's seats. A seat always travels with its DSP. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `dsp` | RegistrableDomain | REQUIRED | The DSP's primary domain | +| `ids[]` | Array[String] | OPTIONAL | The ids the DSP issues, the same values OpenRTB 2.6 carries in `Deal.wseat` | + +**`TaxonomyCodes`** holds codes in one taxonomy. Each field that holds it names the +list `taxonomy` comes from and whether an id covers its descendants. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `taxonomy` | Integer | REQUIRED | A value from the list the field names | +| `ids[]` | Array[String] | REQUIRED | Ids in that taxonomy | + +**`Interval`** is an absolute window, with `end` later than `start`. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `start` | Instant | REQUIRED | The first second | +| `end` | Instant | REQUIRED | The last second, inclusive | + +**`Range`** bounds a value of type `T`, written `Range[T]`. At least one bound is +present, and `min` is no greater than `max`. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `min` | T | OPTIONAL | The lower bound | +| `max` | T | OPTIONAL | The upper bound | + +### 2.3 Publication + +A seller MAY publish `https:///.well-known/openproposal.json`, a +`ProposalManifest`. + +**`ProposalManifest`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `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 | + +- At least one of `ETag` and `Last-Modified` is REQUIRED, so buyers can poll cheaply. + § 8 asks whether both are. +- Only shared proposals (§ 4.3) appear, and within them only line items whose + `visibility` (§ 5.8) is `public` and whose `status` is `published`. + `registry_gated` and `named` line items do not. +- A buyer discards an entry whose `seller.domain` differs from the domain in the + URL it requested, before any redirect. +- `full_url` returns only what the requester may read (§ 5.8), so a request with + no authenticated identity gets only `public` line items. +- Manifest entries MUST match the current version of each proposal. A stale + entry is a seller error. + +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`. Asks, holds, and commits run on a proposal issued to the buyer + (§ 4.3). + +### 2.4 Errors + +A party that rejects a request MUST return `errors[]`, one or more +`ErrorObject`s. This specification defines the object. The binding that carries +the request carries the errors. + +A seller's decline of a resolved `commitment`, or of a hold, answers a valid +request and is not an error. What a party answers when request authentication +fails is the AAMP agent identity and signing profile's to state. + +**`ErrorObject`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `code` | VocabularyTerm | REQUIRED | A core code (below) or an extension code | +| `detail` | String | REQUIRED | Human-readable. A receiver SHOULD NOT parse it | +| `pointer` | FieldPointer | OPTIONAL | Into the object this specification defines that the request carries, to the value that caused the error | +| `version` | Integer | REQUIRED when the request names a proposal the requester may read | The proposal's `version` when the error was decided | +| `retry` | Enum | REQUIRED | `same` \| `refetch` \| `none`. `same`: the same request can succeed later. `refetch`: a new attempt, composed on the current `version`, can succeed. `none`: only a changed request can succeed. On a commitment attempt, `same` keeps the `idempotency_key` and such an error is not a stored result (§ 5.6.1), and `refetch` and `none` take a new `idempotency_key` | + +Core codes: `validation` \| `not_found` \| `not_offered` \| `unresolved_reference` \| +`not_committable` \| `stale_version` \| `expired` \| `idempotency_conflict` \| +`not_eligible` \| `record_mismatch`. + +| `code` | Returned when | +| :--- | :--- | +| `validation` | A value breaks a rule of this specification and no other code applies | +| `not_found` | A named proposal or line item does not exist, or the requester may not read it (§ 5.8) | +| `not_offered` | A buyer value is outside what the seller declared (§ 3.1, § 3.2, § 4.3.2, § 5.5) | +| `unresolved_reference` | A `DocumentRef` (§ 2.2) does not resolve, or its content does not match its `digest` | +| `not_committable` | A line item's `status` rules out an ask (§ 4.3.2), a `requires` that is unmet rules out a `commit` (§ 5.6.3), or a seller rejects a `commit` before `offer_window.start` (§ 5.6.1) | +| `stale_version` | An ask is stale (§ 4.1.3) | +| `expired` | `offer_window.end` or `price_valid_until` has passed | +| `idempotency_conflict` | An `idempotency_key` is reused with a different body | +| `not_eligible` | The buyer or the sender fails an eligibility or authority check the seller applies (§ 5.8) | +| `record_mismatch` | A `record_digest` in the request does not equal the one the receiver recomputes (§ 4.1.1) | + +An `ErrorObject` is open. A receiver that does not recognize a code, or a key of +the object, acts on `retry`. + +Informative: over HTTP, `validation`, `not_offered`, and +`unresolved_reference` fit 400 Bad Request; `not_eligible` 403 Forbidden; +`not_found` 404 Not Found; `not_committable`, `stale_version`, and +`record_mismatch` 409 Conflict; `expired` 410 Gone; `idempotency_conflict` 422 +Unprocessable Content. An error with `retry: same` fits 503 Service Unavailable. + +### 2.5 Extensions + +The proposal and each line item MAY carry +`ext`, an `Ext`. Each key MUST be a `Domain` that its owner controls, and each value +is an object that the owner defines. A seller's own entries MUST use its +`seller.domain` as the key. + +```yaml +ext: + examplepub.example: { placement_code: "BD-ROS-300x250-Q4" } ``` -- 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. +- An `ext` entry MUST NOT change the meaning of a field this specification + defines. A term the counterparty must understand to transact MUST NOT be an + `ext` entry; it is a field or a vocabulary term. +- A receiver MUST ignore an `ext` entry it does not understand, and MUST NOT + reject a document because of it. It MUST keep `ext` unchanged when it + stores or forwards the document. +- In an object whose keys this specification lists, a key outside `ext` that + it does not list is invalid. The open objects `DocumentRef` (§ 2.2) and + `ErrorObject` (§ 2.4) are unaffected. +- On the proposal and a line item, `ext` is part of the Full Representation and + of the record frozen at `agreed` (§ 4.1.1), and a seller change to it is a + revision. +- Apart from an unknown term (§ 2.2), a receiver MAY skip a whole line item it + cannot read, without assenting to it. It MUST NOT keep a line item after removing + fields from it. + +### 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), `Settable[T]`, +`Narrowing[T]`, and `SettableRange[T]` (§ 3.2) take other types. `T`, `K`, and `V` +stand for a type's arguments, and a Type cell inside that type's table may name them. +`Enum` is a closed list of strings, written in the row that uses it, and a generic +never takes it: a list a generic takes has a name. A closed list of integers written in a row has Type `Integer`. +`Object` holds keys that another definition sets. A lead-in that reads "`X` holds the keys of +`Y`" gives `X` every key of `Y`, with the rules `Y` states on them, and the rows of its +own table. The keys sit flat in `X`, and `X` doesn't repeat them. `Y` may take a type, +such as `Range[T]`. A lead-in that adds "each ``" gives the composed keys that +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[]`, `include[]`, `best_for[]`, and +`all_of[]`. The suffix `_ref` is reserved for `DocumentRef` and `EntryRef`. A Boolean flag may end +`_allowed` or `_required`, and a list never does. + +**Values.** Every term compares byte for byte. A list this specification owns is +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`. + +### 2.7 Security considerations + +This section is informative. It sets no requirement on a party to a transaction. + +**Text is data.** Agents that can spend read this object, and much of it is text +the counterparty wrote: `description`, `specifications`, `best_for[]`, +`not_suitable_for[]`, `seasonality_notes`, `approval`, `creative_policy`, `ext` values, +an error's `detail`, and the document behind a +`DocumentRef`. That text is data, and it can hold instructions aimed at a language +model. An agent keeps it apart from its own instructions, and rests a decision to +assent or spend on the structured fields, not on prose alone. + +**URLs.** A document names URLs its author chose: `proposals[].full_url`, +a `DocumentRef`'s `url`, an `Asset`'s `url`, and a `CreativeDelivery`'s `endpoint`. Fetching one lets the +author choose where a client connects. A client uses `https`, bounds the size of a +response and the number of redirects, and refuses an address inside its own +network, on every redirect as well. § 5.4.1 covers what inspecting a creative may +request. + +**Digests.** A `digest` shows that the bytes are the ones a `DocumentRef` names. It +does not show who wrote them, or that they are safe to act on. A client compares +it before reading the bytes, and `unresolved_reference` (§ 2.4) reports a mismatch. + +**Buyer lists.** `exclusions`, `buyer_supplied.segments[]`, and +`buyer_supplied.suppression_list` show a buyer's strategy and can hold data about +people. A party that holds them limits who can read them, in stored records and +logs as well as in responses. § 5.8 states the disclosure rule for `exclusions`. --- @@ -119,53 +486,149 @@ Every field carries exactly one marker. | Marker | Meaning | | :--- | :--- | | `immutable` | Set once at creation. Neither party may change it. | -| `seller-set` | The seller sets it and MAY revise it while status is `draft` or `published`. The buyer cannot change it directly. | -| `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. | +| `seller-set` | The seller sets it and MAY revise it while the `status` of the object that carries it is `draft`, `published` or `under_review`. § 4.3.2 states what a revision does to a line item `under_review`. The buyer cannot change it directly. | +| `selectable` | The buyer chooses from a seller-declared set. Each option MAY carry a price delta. Resolves without a round trip. | +| `settable` | The buyer supplies a value within seller-declared bounds. Resolves without a round trip. | | `supplied` | The buyer provides data or an artifact. The seller does not pre-populate it. | -| `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. | +| `requestable` | The buyer MAY ask for a value outside the declared space. An ask changes the value only through a seller `grant` or `counter` (§ 4.3.1). A change on the seller's own initiative follows § 4.1.3. | +| `derived` | Computed by a rule this specification states. Neither party sets it directly. | +| `lifecycle` | Live state of the object. The party its section names moves it between the stated values. Not a term. | +| `recorded` | Written by the party that acted. Append-only: an entry is never edited or removed. Not a term. | +| `asserted` | The seller states it from data the buyer can't recompute. Not a term, and it changes without a new version. | +| `mixed` | The keys of the object carry different markers: in the Mutability column of its key table, in its lead-in for composed keys, or on its rows for `pricing[]`. | + +**Whose `status`.** The proposal's (§ 4.1) for a field defined in § 4; the line +item's (§ 5.1) for a field defined in § 5, including `seller-set` sub-fields of +a `mixed` field. + +§ 4.3, not the `seller-set` revision rule, governs the seller's answer to a `requestable` ask. **Resolution without a round trip.** `selectable`, `settable` and `supplied` fields MUST resolve without a round trip — everything needed to validate and price them is declared in the proposal. Only `requestable` fields require the counterparty to respond. -A field is never silently reverted. An unset `selectable` or `settable` field -takes its declared `included[]` or default, and that default MUST be explicit in -the proposal. +Resolution is validation and pricing, not acceptance. A resolved `commitment` is +an offer to buy (§ 5.6.1). + +A field is never silently reverted. With no buyer write, what § 3.1 and § 3.2 put in +effect applies, and the seller declares it in the proposal. + +`selected[]` and `value` are keys of the field and carry no marker of their own. The +buyer MUST NOT alter the keys the seller declares beside them. ### 3.1 Selectable structure -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 | The options in the base rate, by option id | +| `selected[]` | Array[String] | OPTIONAL | The buyer's choice, by option id | +| `deltas` | Map[String, Delta] | OPTIONAL | The price of an option outside `included[]`, keyed by option id. An option with no entry has no delta | +| `max_select` | Integer | OPTIONAL | The most options that may be in effect | + +**Option ids.** A string option's id is its value. An object option's id is its +REQUIRED `id`, unique in the field. + +**In effect.** `selected[]` is the complete set in effect and replaces `included[]`. +With no `selected[]`, `included[]` is in effect. `max_select` counts the whole set. An +empty set is valid only where the field gives it a meaning. Dropping an option in +`included[]` doesn't lower the rate. + +Options of different fields combine freely, apart from a rule this specification +states between them. A seller whose options don't combine publishes separate line +items. + +**`Delta`** keys. At least one key is present. § 5.6 states how they enter `agreed_rate`. -A field marked `selectable` that carries a bare value and no `available[]` MUST -be treated as `seller-set`. +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `delta_cpm` | Number | OPTIONAL | Added to the rate of a `CPM` entry, and to no other | +| `delta_fraction` | FractionDelta | OPTIONAL | Multiplies the rate of any entry by 1 plus this value. `0.20` is 20 percent more | +| `delta_flat` | Number | OPTIONAL | A charge on any entry, outside the rate | -`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. +An option priced only by `delta_cpm` MUST NOT resolve against any other entry. ### 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). +A field marked `settable` declares its bounds in one of two forms, and the buyer +writes one key, `value`. `frequency_cap` holds its form in `count`. The keys of +`commitment` are the one exception: `commitment_bounds` holds their bounds (§ 5.6, +§ 5.6.1). + +**`Settable`** is the envelope form. `value` only narrows `allowed[]`. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `allowed[]` | Array[T] | REQUIRED | The entries the buyer narrows within | +| `default` | Narrowing[T] | REQUIRED | In effect when `value` is absent | +| `value` | Narrowing[T] | OPTIONAL | The buyer's value | + +**`Narrowing`** keys. Exclusion wins. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `include[]` | Array[T] | REQUIRED | Each entry MUST be covered by an `allowed[]` entry | +| `exclude[]` | Array[T] | OPTIONAL | Each entry MUST be covered by an `include[]` entry | + +An entry covers itself and what the field says it contains. Nothing else is assumed. -A field marked `settable` that carries a bare value and no bounds MUST be -treated as `seller-set`. +**`SettableRange`** holds the keys of `Range[T]` (§ 2.2) and these. It is the range +form. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `default` | T | REQUIRED | In effect when `value` is absent, between the bounds | +| `value` | T | OPTIONAL | The buyer's value, between the bounds inclusive | ### 3.3 Re-computation -Because audiences, properties and formats are `selectable`, `addressable_scale` -and `availability` MUST be recomputed against the buyer's actual selection. A -single declared figure is correct only for the default composition. +Because audiences, properties, formats, and `markets` are `selectable`, and a +`settable` targeting `value` and buyer `exclusions` (§ 5.5) narrow delivery, +`addressable_scale` and `availability` MUST be recomputed against the buyer's +actual selection, values, and exclusions. A single declared figure is correct only +for the default composition. + +A buyer reads these figures by sending the seller an `AvailabilityRead`. The read changes +no `status`, writes no history, reserves nothing, and doesn't limit a later decline +(§ 5.6.1). + +**`AvailabilityRead`** holds the keys of `BuyerWrites` (§ 3.4) and these: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `offer` | VersionRef | REQUIRED | The line item and the `version` whose options the figures are computed against. `line_item_id` is REQUIRED here | +| `flight` | Interval | REQUIRED | The flight, as `commitment.flight` (§ 5.6.1) | + +### 3.4 Buyer writes + +**`BuyerWrites`** holds what a buyer writes on one line item. It is a strict path +subset of the line item: a listed path has the type it has there, and no other path +appears. Every path is OPTIONAL. A buyer artifact delivered after agreement +(§ 4.1.1), such as `assets[]`, is not a write. + +`BuyerWrites` lists these paths, where `` is a field marked `selectable`, +and `` is a field marked `settable` other than `frequency_cap`: + +```text +.selected[] +.value +frequency_cap.count.value +exclusions +buyer_requirements.seats +buyer_requirements.seat_owner +``` + +**`LineItemWrites`** holds the keys of `BuyerWrites` and this one. It is the buyer's writes +on one line item. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `commitment` | Commitment | REQUIRED | The line item's `commitment` (§ 5.6.1) | --- @@ -178,30 +641,417 @@ items. ### 4.1 Identity and lifecycle -| Field | Type | Mutability | Description | +| Field | Type | Required | Mutability | Description | +| :--- | :--- | :--- | :--- | :--- | +| `proposal_id` | String | REQUIRED | immutable | Identifies the proposal. Unique within `seller.domain` | +| `version` | Integer | REQUIRED | derived | Monotonically increasing revision number, starting at 1, held by the seller. Each version is a snapshot of the proposal's offered terms (§ 4.1.3). A version is frozen at the earliest of: the proposal's `status` leaving `draft`; a `negotiation_history[]` entry or a `commitment` naming it; a counterparty receiving it in any representation (§ 2.1). The offered terms of a frozen version MUST NOT change; a change to them creates version `n+1`. The values § 4.1.3 lists as not offered terms change without a new version | +| `spec_version` | String | REQUIRED | seller-set | The version of this specification the document follows, such as `3.0` | +| `seller` | Party | REQUIRED | immutable | The selling entity. `seller.domain` is the seller's identifier for all purposes, including `assent[]`. `legal_name` is REQUIRED here | +| `buyer` | Party | REQUIRED when `type` is `custom` or `assent[]` is present | immutable | The entity that assents, usually the agency. Its presence makes the proposal issued to that buyer (§ 4.3). Whom the spend is for is `commitment.advertisers[]` (§ 5.6.1), and whether the buyer may bind that advertiser is authorization (§ 1.2). `legal_name` is REQUIRED here | +| `type` | Enum | REQUIRED | immutable | `standard` \| `custom` | +| `proposal_template_id` | String | OPTIONAL | immutable | Links a proposal to the seller's template. Opaque to buyers; used for the seller's reporting | +| `issued_from` | VersionRef | OPTIONAL | immutable | The shared proposal and version an issued proposal was made from (§ 4.3). A pointer: nothing is inherited through it | +| `status` | ProposalStatus | REQUIRED | derived | Computed from the line items (§ 4.3.3) | +| `record_digest` | Digest | OPTIONAL | derived | Of the binding record of this document over every line item it contains (§ 4.1.1). Either party MUST be able to recompute it from the document alone. Verifiable only against the Full Representation; a Summary Representation or manifest entry MAY carry it as a pointer | +| `assent[]` | Array[AssentEntry] | OPTIONAL | recorded | Assent entries. Each entry is written whole by the party its `role` implies, is immutable once written, and MUST NOT be altered or removed by the other party. Entry: `{ role: commitment \| acceptance, version, record_digest, line_item_ids[], signed_at, signer, signature }`. See § 4.1.2. The form of `signer`, the signature container, key discovery and agent authentication are defined by the AAMP agent identity and signing profile, not by this specification | +| `offer_window` | Interval | OPTIONAL | seller-set | The window the offer holds: it does not hold before `start` or after `end`. **This is the offer window, not the campaign flight** | +| `brief_id` | String | REQUIRED when `type` is `custom` | immutable | The buyer's brief this proposal answers | +| `negotiation_history[]` | Array[HistoryEntry] | REQUIRED when the current `version` is frozen (§ 4.1.3) | recorded | Ordered, append-only. Entries MUST NOT be edited or removed. A commit and its acceptance are in `assent[]`, not here | +| `line_items[]` | Array[LineItem] | REQUIRED | seller-set | The line items (§ 5), in the order the seller wrote them | +| `linked_execution` | Enum | OPTIONAL | seller-set | `all`. Every `line_item_id` the version carries is committed together, as though each line item's `requires.all_of[]` named the others (§ 5.6.3). Absent, the proposal is subsettable (§ 2) | +| `ext` | Ext | OPTIONAL | seller-set | See Extensions (§ 2.5) | + +**`HistoryEntry`** keys: + +| Key | Type | Required | Meaning | | :--- | :--- | :--- | :--- | -| `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 | +| `version` | Integer | REQUIRED | The version the act created or refers to (§ 4.1.3) | +| `actor` | Side | REQUIRED | The side that acted | +| `action` | Enum | REQUIRED | `published` \| `requested` \| `granted` \| `countered` \| `declined` \| `withdrawn`. § 4.3.1 says which interaction writes each | +| `paths[]` | Array[HistoryPath] | REQUIRED | The fields the act changed or asked about. MAY be empty | +| `at` | Instant | REQUIRED | When the side acted | + +**`HistoryPath`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `line_item_id` | String | REQUIRED when `requested_value` is present | The line item. When absent, `pointer` is into the proposal | +| `pointer` | FieldPointer | REQUIRED | Into that line item or the proposal | +| `requested_value` | RequestedValue | REQUIRED when the entry's `action` is `requested` | On a `requested` entry, the value the buyer asked for. On a `declined` entry, or a buyer's `withdrawn` entry, the value of the `request` it ended. Absent on every other entry | + +**`RequestedValue`** holds exactly one key: the `requestable` field `pointer` names, in the +form its row states. `pointer` says which `pricing[]` entry. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `list_rate` | Number | OPTIONAL | A `pricing[].list_rate` | +| `commitment_bounds` | Object | OPTIONAL | The line item's `commitment_bounds` | +| `materials_due` | MaterialsDue | OPTIONAL | The line item's `materials_due` | +| `committed_metrics[]` | Array[CommittedMetric] | OPTIONAL | The line item's `committed_metrics[]` | + +`proposal_template_id` MUST NOT be required for a buyer to transact. Buyers never +resolve it. + +**Seller identity.** A proposal names exactly one `seller.domain`. A seller MUST +NOT name a domain it does not control. For this specification one `seller.domain` +is one seller: entities that transact under one domain are one seller here, and +`seller.legal_name` carries the legal name. A selling entity that controls several +registrable domains SHOULD use the same `seller.domain` for every proposal it +publishes as one seller; a seller that publishes proposals under several domains +holds an Agent Registry entry for each, or the entry does not apply. The field is immutable, +so a seller that moves to another domain publishes new proposals. Where a third party +hosts a seller's agent or published files, `seller.domain` is the selling +entity's own registrable domain, never the host's. Continuity of a legal entity +across domains, rebrands, or acquisitions is a fact the Agent Registry attests; this +object does not encode it. Whether to deal with a seller that has no Agent +Registry entry is buyer policy. + +#### 4.1.1 Binding record and record_digest + +The binding record of a document for a set of line items S is the JSON form of +that document in its Full Representation (§ 2.1), restricted to the line items in +S, with the following paths removed and no others. The paths are RFC 9535 JSONPath. +Arrays hash in document order, and no party reorders an array it did not write. The +hold's status is every `hold_status` key except `hold_available`, `hold_duration` +and `hold_scope`. + +``` +$.version +$.status +$.assent +$.record_digest +$.negotiation_history +$.line_items[*].status +$.line_items[*].availability +$.line_items[*].addressable_scale +$.line_items[*].assets +$.line_items[*].buyer_supplied.match_rate_expected +$.line_items[*].pricing[*].seller_rate +$.line_items[*].pricing[*].min_rate +$.line_items[*].hold_status.*, except hold_available, hold_duration and hold_scope +$.line_items[*].commitment.version +$.line_items[*].commitment.idempotency_key +$.line_items[*].commitment.derived_units +$.line_items[*].deal_id_issuance +$.line_items[*].external_references +$.line_items[*].correlation_id +``` + +Each removed path is one of three things: lifecycle state or sequence position +(`version`, `status`, `assent[]`, `record_digest`, `negotiation_history[]`, the +hold's status, `commitment.version`, `idempotency_key`); a seller assertion, an +informative figure, or an identifier minted downstream of agreement +(`availability`, `addressable_scale`, `match_rate_expected`, `derived_units`, +`deal_id_issuance`, `external_references[]`, `correlation_id`); or a value that +is not a term of the agreement: the seller's own figures (`seller_rate`, +`min_rate`; the transacted rate is `agreed_rate`) and buyer artifacts delivered +after agreement (`assets[]`). The requirement on creative (`formats`, `specs`, +`creative_policy`, `materials_due`) is a term. + +Every other value is inside the record: `seller`, `buyer`, and `issued_from`; every +`seller-set` field, including prose (`description`, `specifications`, `content_detail`, +`creative_policy`, `specs`); the seller's value of a `requestable` field, such as +`pricing[].list_rate`, including one a `grant` or `counter` put in force; every option +space (`available[]`, `included[]`, `deltas`, `max_select`, `allowed[]`, `min` / `max`, +`default`); every value the buyer writes under § 3 (`selected[]`, `value`, +`commitment`, data under `supplied` fields other than `assets[]`); +`pricing[].agreed_rate`; and `hold_available`, `hold_duration`, `hold_scope`. + +**Digest input.** The record is computed over the composed record as the parties +exchange it: the Full Representation of `version` as the seller published it to +this buyer, with the buyer's § 3 writes applied. Each party computes it over the +document it transmits or receives, never over its internal store; a value +present in a store but absent from the exchanged document is, by construction, +not a term. A `derived` value inside the record is computed by the rule this +specification states for it; where no rule exists, the two parties' digests +differ and the line item is not agreed. + +**Canonical form.** The digest input is the RFC 8785 (JSON Canonicalization +Scheme) serialization of the binding record, encoded as UTF-8. This +specification names no wire format; for this section JSON is the data model, and +a document carried as YAML is converted to JSON under the YAML 1.2 core schema +before canonicalization. A document whose JSON form contains duplicate object +keys is invalid. Strings, timestamps included, are hashed as written; a party +MUST NOT reformat a value it did not write. Numbers serialize as RFC 8785 +§ 3.2.2.3 prescribes (IEEE-754 double precision, shortest round-trip form). A +producer MUST NOT write a Number that this serialization does not reproduce as +the decimal value written; a Number with at most 15 significant digits inside +the range of a double always does, and a document containing one that does not +is invalid for this section. `24.00` and `24` are the same value. + +`record_digest` is the `Digest` (§ 2.2) of the digest input. + +**Two values.** On a shared proposal no buyer-written value is present, and +the digest identifies the offered terms of that version: what a cache or a +manifest entry compares. On a composed record the +buyer's writes are present, and the digest identifies what this buyer and the +seller agree to. An `assent[]` entry always carries the second. A shared proposal +carries no `assent[]`. + +A receiving party MUST recompute `record_digest` and compare it byte-for-byte +before relying on any `assent[]` entry. A `record_digest` asserted in a document +is a convenience, not evidence. + +#### 4.1.2 Assent + +- `assent[]` is carried on the composed record exchanged between one buyer and + the seller. A proposal MAY carry any number of entries. A line item MUST NOT + be named by more than one live buyer `commitment` entry, nor by more than one + live seller `acceptance` entry. An entry is live until it is void or replaced + under the following rules. +- The parties are the record's `seller` and `buyer` (§ 4.1). `role` names what + the entry does, and it implies the party. `role: commitment` is written by the + buyer. Its `line_item_ids[]` MUST be non-empty, MUST name line items whose + `commitment` object (§ 5.6.1) is populated in the record, and on their own MUST + meet linked execution and each named line item's `requires` (§ 5.6.3). + `role: acceptance` is written by the seller and names the `version`, + `record_digest` and `line_item_ids[]` the seller accepts. Neither entry refers + to the other, and the order in which they are written is not constrained. +- `record_digest` in an entry is the digest of the binding record for the line + items in that entry's `line_item_ids[]` (§ 4.1.1). It equals the + document-level `record_digest` when the document contains exactly those line + items. `version` in an entry is the `version` of the record the digest was + computed over; because `version` is outside the binding record, the entry + binds it explicitly. +- The line items of a buyer `commitment` entry are `agreed` when, and only when, + `assent[]` contains a live seller `acceptance` entry with equal `version`, + equal `record_digest` and equal `line_item_ids[]`, and both signatures verify + under the AAMP agent identity and signing profile. `line_item_ids[]` compare as + sets. Equal digests already imply + equal line item sets; the explicit condition rules out partial acceptance. A + line item for which no such pair exists is not agreed, whatever its `status` + says. +- A seller MUST NOT write an `acceptance` entry after the proposal's + `offer_window.end`, nor for a `commitment` entry it receives after the + `price_valid_until` of the committed `pricing[]` entry of any line item the + `commitment` entry names. + Such a `commitment` entry is void, and so is one unmatched when `offer_window.end` + passes. A `negotiation_history[]` entry at the same `version` with + `actor: buyer, action: withdrawn` or `actor: seller, action: declined`, + written after the `commitment` entry, also voids it. A void entry stays in + `assent[]`, does not count toward `agreed`, and the line items it names MAY + appear in a new `commitment` entry. +- A later entry from the same party whose `version`, `record_digest` and + `line_item_ids[]` equal those of one of its live entries replaces that entry + (a retry after a lost response). A later entry from the same party that names + a line item already in one of its live entries but differs in any of the three + is invalid, and the earlier entry stands. +- The signing input of an entry is the RFC 8785 serialization of the entry with + `signature` removed. `signature` is a detached signature over that input in + the container the AAMP agent identity and signing profile defines, carrying + the key identifier and algorithm. `signer` identifies the agent that signed; + if the container also names the signer, that name MUST equal `signer`. The + form of `signer`, the container and algorithms, key resolution, and how + `signer` is bound to the party `role` implies are defined by the profile. + This specification requires of the profile only that (a) verification needs + only the stored record and key material the signer has published, or a + retained copy of it, and MUST NOT require an interactive request to the + counterparty or a registry lookup; (b) the signature is bound to this artifact + type, so an assent signature cannot be presented as a signature over any other + AAMP artifact; and (c) a signature remains verifiable for the life of the + agreement, including after the signing key is rotated. +- Transitional rule. Until the profile is published, `signer` and `signature` + MAY be absent. An entry without them is an unsigned assent record whose + authenticity rests on the authenticated channel it arrived over, and the + `agreed` rule applies without the signature condition. +- Adding an `assent[]` entry does not change `version`. +- `agreed` is terminal for a line item. Every `seller-set`, `selectable`, + `settable`, and `requestable` value of it and its `pricing[].agreed_rate` MUST + remain as in the version `assent[]` names. A later version of the proposal does + not touch the agreed record: a party verifies an entry over the version it + names (§ 4.1.3), not the current one. A change to a value inside the record is + applied outside it, in the downstream standard the line item lands in (§ 1.1), + against the agreed record as its baseline. + +#### 4.1.3 Offered terms and versions + +**Offered terms.** The offered terms of a version are every value the seller +writes: `immutable` and `seller-set` fields; the seller's value of a `requestable` +field, such as `pricing[].list_rate`; the seller-declared structure of `selectable` +and `settable` fields (`available[]`, `included[]`, `deltas`, `max_select`, +`allowed[]`, `min`, `max`, `default`); and the set of `line_item_id`s the version +carries. The following are not offered terms and MAY change without a new version: `status` (proposal +and line item), `assent[]`, `negotiation_history[]`, `availability`, +`addressable_scale`, the hold's status (every `hold_status` key except +`hold_available`, `hold_duration` and `hold_scope`, which offer the hold), +`deal_id_issuance`, `external_references[]`, `correlation_id`, +`assets[].approval_status`; `pricing[].seller_rate`, which is what the publisher +receives and not a term offered to the buyer; every value the buyer writes (a +`BuyerWrites` value, `commitment`, any other `supplied` field, and a `requestable` +ask); and every `derived` value computed from a buyer's writes (`pricing[].agreed_rate`, +`commitment.derived_units`). A buyer composes against a named `version` and never increments it. + +**Freezing.** A seller MAY edit a proposal in place only while its current +version is not yet frozen (§ 4.1). Two representations of the same +`(proposal_id, version)` whose offered terms differ, compared on the fields and line +items both carry, are non-conformant. A line item that +appears in a frozen version MUST NOT be removed from the proposal; it leaves the +shelf by its `status` (§ 5.1), which is a lifecycle change. Adding a line item +changes the offered terms. + +**Versions.** Each change to the offered terms of a frozen version MUST create +version `n+1` and MUST append exactly one `negotiation_history[]` entry carrying +that `version`, with `actor: seller` and `paths[]` naming every offered-terms +field whose value differs from version `n`. Its `action` is one of +the following: + +- `published` for version 1, appended no later than the moment version 1 is + frozen, and for a change the seller makes on its own initiative, including a + change to `seller-set` prose. +- `granted` or `countered` for a change that answers a buyer's `requestable` + ask: `granted` puts the requested value into force, `countered` puts a different + value into force in its place. + +Only the seller changes offered terms, so only these entries create versions. +Every other entry (`requested`, `declined`, `withdrawn`) carries the version it +refers to. + +**Staleness.** An ask or a hold is on a `version`. It is *stale* when the binding +record (§ 4.1.1) for the line items it names, with any buyer writes applied, +differs between that `version` and the current one. `version` is outside the +record, so a revision that touches only another line item does not make it stale, +and a change to a proposal-level term does. Under `linked_execution: all`, a change +to the set of `line_item_id`s also makes it stale. The seller computes the test +from the versions it retains, and no digest is published for it. For a `commit`, +the seller recomputes `record_digest` over the line items as they stand plus the +buyer's writes. A value equal to the one in the buyer's `assent[]` entry means the +seller MAY accept, at the `version` that entry names. + +**Retention.** A seller MUST keep every version that a `negotiation_history[]` +entry, a `commitment` or an `assent[]` entry names retrievable by +`(proposal_id, version)` for as long as the proposal is retrievable. This +specification does not define how a party fetches a version. ### 4.2 Agent comprehension 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 | + +### 4.3 Lifecycle + +**Shared and issued.** A proposal with no `buyer` is *shared*. A proposal with a +`buyer` is *issued* to that buyer. The seller creates one with `publish`: a new +`proposal_id`, version 1, `buyer`, and, when it is made from a shared proposal, +`issued_from`. A `custom` proposal is issued from the start. `buyer` is immutable, +so every later version stays with that buyer. Each issued proposal has an +independent `status`, `version` and `negotiation_history[]`. + +A buyer that wants an issued proposal sends, over the binding, the ask, hold, or commit +it would make, naming the shared proposal and `version` it read, or a brief. A seller +that issues one answers with `publish`, and the buyer sends its ask, hold, or commit +against the new `proposal_id`. + +Asks, `under_review`, holds, and assent live on a proposal issued to one buyer; a +shared proposal, or any other record more than one buyer can read (§ 5.8), carries +none of them. + +**Asks.** A buyer `request` (`requestable`, not `hold_status`) or `commit` +(§ 5.6.1) is an *ask*: it names a `version` and stays *open* until answered or +withdrawn, or until it goes stale (§ 4.1.3) or void (§ 4.3.2, § 4.3.4). A `commit` is one +act: the buyer's `assent[]` entry and the `commitment` of each line item that entry +names. A `request` is identified by its `line_item_id`, `pointer`, and `requested_value` +(§ 4.1). Two equal ones are one ask while that ask is open, and an equal `request` after +it ends is a new ask. Holds change neither `status` nor `negotiation_history[]`. + +#### 4.3.1 Interactions + +| Interaction | Actor | Effect; history `action` | +| :--- | :--- | :--- | +| `publish` | seller | version 1 of a new or `draft` proposal, or the next `version` for a change to offered terms (§ 4.1.3); `published`. Moving a `draft` line item in a frozen version: neither (§ 4.3.2) | +| availability read | buyer | an `AvailabilityRead` (§ 3.3), answered with the figures § 3.3 names, with no change of state; no entry | +| `request` | buyer | asks a `requestable` value on one line item against a named `version`, stated as a `HistoryPath` (§ 4.1) with `requested_value`; `requested` | +| `grant` | seller | of a `request`: new `version` with the requested value in force as an offered term; `granted` | +| `counter` | seller | of a `request`: new `version` with another value; `countered` | +| `decline` | seller | no new `version`; `declined` | +| `commit` | buyer | `commitment` (§ 5.6.1) pinned to `version`, with the buyer's `assent[]` entry; no entry | +| `accept` | seller | of a `commit`: the seller's `assent[]` entry, no new `version`; no entry | +| `withdraw` | seller: line item or proposal; buyer: its own ask | `withdrawn`. A seller's is one entry. For each line item it moved to `withdrawn`, its `paths[]` holds a `HistoryPath` with that `line_item_id` and the `pointer` `/status` | +| hold request, grant, decline, release | buyer; seller; seller; buyer, or seller on a revision (rule 2) | `hold_status` only; no entry | + +A buyer that turns down an offer, including a counter, takes no action and leaves no entry. + +#### 4.3.2 Line-item status transitions + +A line item moves between `status` values as follows. These transitions are +exhaustive, so a status with none leading out of it is terminal. + +- `draft` → `draft` at a new `version`: seller `publish`. +- `draft` → `published`: seller `publish`. In a frozen version this changes only `status`, + so it makes no new `version` and no entry (§ 4.1.3). +- `draft` → `withdrawn`: seller. +- `published` → `published` at a new `version`: seller `publish`. +- `published` → `under_review`: buyer ask. +- `under_review` → `under_review`: another buyer ask, a seller or buyer act that + ends one ask while another stays open, or a seller `publish` that makes no open + ask stale. +- `under_review` → `published`: the last open ask ends by seller `grant`, + `counter`, or `decline`, by buyer `withdraw`, or by going stale on a new + `version` (rule 2). +- `under_review` → `agreed`: seller `accept` of a `commit` (§ 4.1.2); open asks are void. +- `published` | `under_review` → `sold_out`: seller, never while `held`; open + asks are void. +- `published` | `under_review` | `sold_out` → `withdrawn`: seller, never while + `held`, nor until the latest `price_valid_until` of its `pricing[]` entries; open + asks are void. +- `sold_out` → `published`: seller, when quantity returns, with no new `version`, + because `availability` is not an offered term. +- `published` | `under_review` | `sold_out` → `expired`: `offer_window.end` passes + (§ 4.3.4). + +A *firm entry* is a `pricing[]` entry with a `price_valid_until`. It holds until +that date or `offer_window.end`, whichever comes first. While it holds, a seller +revision MUST NOT change that entry, and MUST NOT end it sooner by moving +`offer_window.end`. A `grant` or `counter` of a `request` on that entry MAY lower its +`list_rate`. A revision that narrows who may commit (§ 5.8) does not apply, while the +entry holds, to a buyer that was eligible when the entry was first offered. +The seller of an issued proposal MUST answer a resolved `commit` under it that arrives +while the entry holds, on or after `offer_window.start`, and passes the checks of +§ 5.6.1 and any authority check the seller applies (§ 5.8): accept it, or decline it +on an allowed ground. The allowed grounds are lack of availability, an exclusivity +conflict (§ 5.6), and a ground stated in the `terms_ref` in force when the entry was +first offered. `sold_out` is lack of availability: a `commit` that arrives while the +line item is `sold_out` returns `not_committable` (§ 2.4). + +Two rules apply: + +1. A stale ask (§ 4.1.3) is not open and not evaluated. The seller rejects it with + `stale_version` (§ 2.4). +2. A seller MAY revise a line item while an ask is open or a hold runs. The + revision is a new `version` (§ 4.1.3). An ask it makes stale ends (rule 1), and + the seller MUST release a `held` hold it makes stale or re-offer it at the new + `version`, where `state` stays `held`. A `commit` MUST NOT carry a `requestable` + ask: `accept` makes no new `version`, so another value takes a separate `request`. + +#### 4.3.3 Proposal status + +A proposal's `status` is the first of the following that applies: + +1. `draft` when every line item is `draft`. +2. `under_review` when any line item is. +3. `published` when any line item is `draft`, `published` or `sold_out`. +4. `agreed` when any line item is `agreed`. +5. Otherwise `expired` when any line item is `expired`, else `withdrawn`. + +A seller `withdraw` of the proposal moves to `withdrawn` every line item that +§ 4.3.2 lets the seller withdraw. + +#### 4.3.4 Expiry + +| Instant | Ends | Effect when passed | +| :--- | :--- | :--- | +| `offer_window.end` | the offer window | every `published`, `under_review`, or `sold_out` line item is `expired`; open asks are void | +| `pricing[].price_valid_until` | the firm entry | `status` unchanged; the seller MUST NOT accept a later `commit` under that entry, and SHOULD `publish` or `withdraw` | +| `hold_status.hold_expires_at` | the hold | `hold_status.state` becomes `expired`; `status` unchanged | + +`hold_expires_at` MUST NOT exceed `offer_window.end` or the earliest +`pricing[].price_valid_until`. --- @@ -212,144 +1062,596 @@ channel. ### 5.1 Identity -| Field | Type | Mutability | Description | -| :--- | :--- | :--- | :--- | -| `line_item_id` | String | immutable | Identity within the proposal | -| `status` | Enum | derived | `draft` \| `published` \| `under_review` \| `agreed` \| `withdrawn` \| `sold_out` \| `expired`. Independent of the proposal's status, so one line item may sell out while the rest of the shelf stays live | -| `channel` | Enum | immutable | `display` \| `video` \| `audio` \| `ctv` \| `dooh` \| `native`. The discriminator for how the rest of the line item is read | +| Field | Type | Required | Mutability | Description | +| :--- | :--- | :--- | :--- | :--- | +| `line_item_id` | String | REQUIRED | immutable | Identity within the proposal | +| `status` | LineItemStatus | REQUIRED | lifecycle | Independent of the proposal's status, so one line item may sell out while the rest of the shelf stays live | +| `channel` | MediaChannel | REQUIRED | immutable | Core terms in § 5.1.1. The discriminator for how the rest of the line item is read | +| `ext` | Ext | OPTIONAL | seller-set | See Extensions (§ 2.5) | + +#### 5.1.1 channel + +An impression's channel is the first row of this table that the impression +matches. Conditions are stated in +[AdCOM 1.0](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md) +terms: the +[distribution channel](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#abstract_distributionchannel), +the ad's media +([`Placement`](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#object_placement) +subtypes) and the +[device type](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#list_devicetypes). +Where an impression accepts more than one medium, its media is that of the ad +served. + +| `channel` | Condition | +| :--- | :--- | +| `dooh` | Distribution channel `Dooh` | +| `audio` | Audio ad (`AudioPlacement`) | +| `ctv` | A TV device type | +| `native` | Native ad (`DisplayPlacement.nativefmt`), including native ads with video assets | +| `video` | Video ad (`VideoPlacement`) | +| `display` | Any other display ad (`DisplayPlacement`) | + +**`MediaChannel`** is a `VocabularyTerm` (§ 2.2) whose core terms are the rows of this +table. An impression a row matches has that row's channel, so an extension term +covers only impressions no row matches. + +Device types are values of AdCOM 1.0 List: Device Types, each written as its +name followed by its code. The *TV device types* are Connected TV (3), Connected +Device (6), and Set Top Box (7). The *mobile device types* are Mobile/Tablet - +General (1), Phone (4), and Tablet (5). + +- The seller MUST serve a line item only on impressions of its `channel`. + Placement type, such as interstitial or rewarded + ([OpenRTB 2.6](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/1debba8001f40eb14597102dec3205afddf811d4/2.6.md#objectimp) + `video.plcmt`, `imp.instl` and `imp.rwdd`), does not change the channel. +- Impressions in linear TV, including addressable linear, are outside this + table: they have no core `channel` term in 3.0. ### 5.2 Inventory composition -| Field | Type | Mutability | Description | -| :--- | :--- | :--- | :--- | -| `properties` | Object | selectable | `available[]` of `{ id, name, domains, supported_formats }`, `included[]`, `pricing`, `max_select` | -| `content_detail` | Object | seller-set | Shows, series, sections, network verticals | -| `environments` | Object | selectable | `available[]` from `web_desktop` \| `web_mobile` \| `app_mobile` \| `ctv` \| `dooh` \| `audio_streaming` | +| Field | Type | Required | Mutability | Description | +| :--- | :--- | :--- | :--- | :--- | +| `properties` | Selectable[PropertyOption] | OPTIONAL | selectable | Options of `{ id, name, domains[] }` | +| `content_detail` | Object | OPTIONAL | seller-set | Shows, series, sections, network verticals | +| `environments` | Selectable[Environment] | OPTIONAL | selectable | The distribution channels and device types the line item runs on. See 5.2.1. Absent: the seller promises no environment | + +#### 5.2.1 environments + +**`Environment`** is a value of this table. Each value is the set of (distribution +channel, device type) pairs formed from its row, in AdCOM 1.0 terms. An impression +matches the value when both its distribution channel and its device type are in the +row. Device types are as in 5.1.1. + +| `environments` | Distribution channel | Device types | +| :--- | :--- | :--- | +| `web_desktop` | `Site` | Personal Computer (2) | +| `web_mobile` | `Site` | The mobile device types | +| `app_mobile` | `App` | The mobile device types | +| `ctv` | `Site` or `App` | The TV device types | +| `dooh` | `Dooh` | Any | + +Each `environments.available[]` value MUST match at least one impression to +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 | | :--- | :--- | :--- | :--- | -| `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 }` | +| `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` | ApprovalStatus | OPTIONAL | The seller's review outcome. The seller moves it | lifecycle | + +**`CreativeDelivery`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `method` | VocabularyTerm | REQUIRED | Core terms: `buyer_hosted` \| `seller_hosted` \| `api` \| `platform_managed` | +| `protocol` | String | OPTIONAL | How the creative is delivered, such as a VAST version or a JavaScript tag | +| `secure_required` | Boolean | OPTIONAL | Whether the creative must load over a secure connection | +| `endpoint` | Url | OPTIONAL | Where the buyer sends creative | + +**`ProductionOption`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `id` | String | REQUIRED | Option id (§ 3.1) | +| `custom_unit` | String | OPTIONAL | The custom unit built | +| `build_owner` | Side | OPTIONAL | Which side builds it | +| `lead_time` | Duration | OPTIONAL | Time the build needs | +| `cost` | Number | OPTIONAL | Cost of the build | + +#### 5.4.1 Creative inspection and previews + +For this section, inspection means evaluating a creative outside live campaign +delivery, including discovery, composition, submission checks and previews. A +production notification is an event report intended for a campaign's live +measurement or delivery diagnostics. + +An implementation performing inspection MUST NOT send any production +notification. This includes requesting an event URL solely to check its +availability, regardless of the HTTP method used. + +An implementation MAY retrieve creative documents, follow VAST wrappers and +inspect referenced media as needed for its checks. Permission to retrieve these +resources does not extend to firing their event URLs. Retrieval can itself be +logged by the resource owner; this requirement does not imply that inspection +produces no server activity. + +A landing-page inspection remains subject to the same notification prohibition, +including any redirect requests. The presence of a URL in VAST `` +does not establish that requesting it is safe for inspection. The inspector +MUST NOT request a destination or redirect that sends a production notification; +it MAY use a separate inspection URL or the receiving service's documented test +mode under the conditions below. + +A preview that executes creative or verification code MUST prevent that code +from sending production notifications. Synthetic events MAY be sent to test +endpoints, or through a test mode that the receiving service documents as +excluding those events from production measurement and delivery diagnostics. An +inspector's own test flag is insufficient unless the receiving service supports +it. + +Inspection failures MUST be reported through diagnostics, without firing a +production VAST `` or `verificationNotExecuted` URL. An implementation +MUST NOT report a check that was skipped or could not finish as passed. + +These requirements apply to inspection only. During live delivery, +implementations remain subject to the tracking and error-reporting requirements +of the applicable execution standard. ### 5.5 Targeting envelope -Each field declares bounds, not a value. A bare value means the field is -`seller-set`. +`geo`, `device`, `daypart`, and `frequency_cap` declare bounds, not a value (§ 3.2), and +one that is absent declares none. `markets` declares options (§ 3.1). For +`exclusions_accepted` and `exclusions`, the seller declares what it can enforce, and the +buyer supplies its lists. + +| Field | Type | Required | Mutability | Description | +| :--- | :--- | :--- | :--- | :--- | +| `geo` | Settable[GeoCode] | OPTIONAL | settable | Where the line item runs. `allowed[]` of `GeoCode` (below). A line item MUST NOT declare both `geo` and `markets` | +| `markets` | Selectable[GeoCode] | OPTIONAL | selectable | For a line item priced by market. See `GeoCode` below | +| `device` | Settable[Integer] | OPTIONAL | settable | `allowed[]` of [AdCOM 1.0 List: Device Types](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/26c59ba1b235cd8d938c9877409e506f1b8d3f0c/AdCOM%20v1.0%20FINAL.md#list_devicetypes) codes, the values of OpenRTB 2.6 `device.devicetype`. It narrows `environments` (§ 5.2.1). Mobile/Tablet - General (1) covers Phone (4) and Tablet (5) | +| `daypart` | Settable[ClockWindow] | OPTIONAL | settable | `allowed[]` of `ClockWindow` (below). A window covers any window inside it | +| `time_zone` | ZoneName | REQUIRED when `daypart` or `frequency_cap` is present | seller-set | The clock for `daypart` and a calendar `frequency_cap.period` | +| `frequency_cap` | FrequencyCap | OPTIONAL | settable | See below | +| `exclusions_accepted` | Object | OPTIONAL | seller-set | What the seller can enforce: `{ types[], max_entries, enforcement, verification[] }`. See below | +| `exclusions` | Object | OPTIONAL | supplied | The buyer's block lists: `{ domain[], app_bundle[], iab_content_category[], keyword[], list_ref }`. See below | + +**Block lists.** The seller declares which exclusion types it accepts and how it +enforces each; the buyer supplies its lists within that declaration. + +```yaml +exclusions_accepted: + types: [domain, app_bundle, iab_content_category, keyword] + max_entries: { domain: 5000, keyword: 500 } + enforcement: { domain: pre_serve, app_bundle: pre_serve, + iab_content_category: pre_serve, keyword: best_effort } + verification: ["IAS", "DoubleVerify"] + +exclusions: + domain: ["example-gambling.example"] + app_bundle: [{ platform: roku, bundle: "123456" }] + iab_content_category: ["IAB-CT3.0:"] # IAB Content Taxonomy ids + keyword: ["crypto"] + list_ref: { url: "", sha256: "" } +``` + +- Each `app_bundle[]` entry is `{ platform, bundle }`. `bundle` is the OpenRTB + 2.6 `app.bundle` value; `platform` names the store (`ios` \| `android` \| + `roku` \| `fire_tv` \| `samsung_tv` and other store platforms). A seller MUST + reject an entry whose platform it cannot determine, rather than apply it + across stores, with `validation`. +- `iab_content_category[]` entries are IAB Content Taxonomy ids. +- `enforcement` is `pre_serve` or `best_effort` per type. +- A buyer MUST NOT supply an exclusion type absent from + `exclusions_accepted.types`. A seller MUST reject it deterministically + rather than ignore it, with `not_offered`. +- A seller MUST NOT present `best_effort` enforcement as a guarantee. +- `list_ref` MUST be hash-pinned, and resolved into the stored record at + `agreed`. + +**`GeoCode`** is a String: an ISO 3166-1 alpha-2 country code, an ISO 3166-2 +subdivision code, or an extension `VocabularyTerm` for any other area, such as a +metro list. The country of an ISO 3166-2 code is the part before the hyphen, and a +country covers every subdivision code it prefixes. An extension term covers only +itself. § 1.1 states the conversion to OpenRTB 2.6. + +```yaml +geo: + allowed: ["US"] + default: { include: ["US"] } + value: { include: ["US"], exclude: ["US-TX"] } # the buyer's write +``` + +**Market-priced line items.** A seller whose rate differs by market declares `markets`: + +```yaml +markets: + available: ["SG", "TH", "ID", "MY"] + included: ["SG", "TH"] + deltas: { "ID": { delta_cpm: 18.00 }, "MY": { delta_cpm: 21.00 } } + max_select: 4 + selected: ["SG", "ID"] # the buyer's write: runs in these two, and pays the ID delta +``` + +**`ClockWindow`** is a repeating window of civil time, deliberately not an `Interval`. +A window that crosses midnight is two windows. -| Field | Type | Mutability | Description | +| 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 | +| `days[]` | Array[DayOfWeek] | REQUIRED | The days the window repeats on | +| `start` | ClockTime | REQUIRED | The first minute. Not `24:00` | +| `before` | ClockTime | REQUIRED | The first minute after the window, later than `start` | + +**`ClockTime`** is a String: `HH:MM` on a 24-hour clock, two digits each, from `00:00` +to `23:59`, or `24:00`, the end of the day. + +**`DayOfWeek`** is `monday` \| `tuesday` \| `wednesday` \| `thursday` \| `friday` \| +`saturday` \| `sunday`. + +```yaml +# Late fringe, 23:00 to 01:00, Monday night into Tuesday +daypart: + allowed: + - { days: [monday], start: "23:00", before: "24:00" } + - { days: [tuesday], start: "00:00", before: "01:00" } + default: + include: + - { days: [monday], start: "23:00", before: "24:00" } + - { days: [tuesday], start: "00:00", before: "01:00" } +time_zone: "America/New_York" +``` -### 5.6 Commercial terms +**Clock.** Windows follow daylight-saving rules: a skipped +hour matches no instant; a repeated hour matches both. Like the calendar Day of +[OpenDirect 2.1 `Line.frequencyinterval`](https://github.com/InteractiveAdvertisingBureau/OpenDirect/blob/82bc294314aba017fc639ba634aecc53adfd675f/OpenDirect.v2.1.final.md#object_line), +a `frequency_cap.period` other than `flight` is a calendar period in `time_zone`; weeks start on +Monday. -| 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 | +**`FrequencyCap`** keys: -#### 5.6.1 commitment +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `count` | SettableRange[Integer] | REQUIRED | The cap. The buyer's `value` is the count, and `period` and `per` stay the seller's | +| `period` | TimeUnit | REQUIRED | The bucket the count runs over | +| `per` | Enum | REQUIRED | `device` \| `household` \| `listener`. Who is capped. It matters: CTV typically caps at household, audio at listener | + +### 5.6 Commercial terms -Absent on a published standard proposal. Populated during composition. Frozen -when status becomes `agreed`. +| 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[].list_rate` | Number | REQUIRED | requestable | The rate before options. A buyer MAY request another value, and a granted value is the `list_rate` of the next `version` | +| `pricing[].agreed_rate` | Number | REQUIRED when this is the committed entry | derived | The transacted rate (below). Absent on every other entry | +| `pricing[].seller_rate` | Number | OPTIONAL | seller-set | What the publisher receives | +| `pricing[].price_valid_until` | Instant | OPTIONAL | seller-set | How long the entry holds (§ 4.3.2). Distinct from the proposal's `offer_window.end` | +| `pricing[].currency` | String | REQUIRED | seller-set | ISO-4217 | +| `pricing[].min_rate` | Number | OPTIONAL | seller-set | A hint: the lowest `list_rate` the seller says it will consider. It grants nothing. Not a term, not the downstream auction floor, and never sent downstream | +| `availability` | Object | OPTIONAL | asserted | `{ unit, basis, as_of, granularity, type: forecast \| reservable, quantity }`, with `as_of` an `Instant` and `granularity` a `CalendarUnit`. Recomputed against the buyer's selection | +| `commitment_bounds` | Object | OPTIONAL | requestable | The envelope inside which a buyer may commit, and a buyer MAY request another: `{ min_impressions, max_impressions, min_budget, max_budget }` | +| `commitment` | Commitment | OPTIONAL | mixed | **The buyer's actual commitment.** See 5.6.1 | +| `hold_status` | HoldStatus | OPTIONAL | mixed | See 5.6.2 | +| `min_spend` | Number | OPTIONAL | seller-set | Minimum spend for this line item | +| `cancellation_policy` | Selectable[CancellationOption] | OPTIONAL | selectable | The cancellation rights the seller offers | +| `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 `commit` for this line item to be committable. See 5.6.3 | + +The *committed entry* is the `pricing[]` entry the commitment is priced against. + +**`agreed_rate`** is computed on the committed entry from the options in effect +(§ 3.1), in exact decimal, and rounded once, half up, to 6 decimal places: + +```text +agreed_rate = (list_rate + sum of delta_cpm) × product of (1 + delta_fraction) +``` -| 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 | +A delta counts when its option is in effect and not in `included[]`, on any field of the +line item marked `selectable`. A `delta_cpm` enters only a `CPM` entry (§ 3.1), and +the option's other deltas still count. So `list_rate` 24.00 with one `delta_cpm` of +3.00 and one `delta_fraction` of 0.02 is 27.54. `delta_flat` stays outside it. + +#### 5.6.1 commitment -A request carrying an `idempotency_key` already seen MUST return the original -result and MUST NOT create a second commitment. +Absent on a shared proposal (§ 4.3). The buyer writes it on its composed record +(§ 4.1.1), and a `commit` carries it to the seller. The buyer writes its `settable` keys +itself, and they have no `default` (§ 3.2). A +`commitment` resolves under § 3 when the buyer is eligible (§ 5.8), every `selected[]` +and `value` on the line item is within what the seller declared (§ 3.1, § 3.2), and +the commitment fits `commitment_bounds` and `min_spend`, is not stale (§ 4.1.3), +and meets `requires` (§ 5.6.3). A resolved `commitment` is an offer to buy. It +binds only when the seller accepts it; +`assent[]` (§ 4.1) records that acceptance for the binding `version`. A seller +MAY decline a resolved `commitment`; the buyer MAY recompose. A decline for lack +of availability MUST return `availability` recomputed against the buyer's +composition; the seller's determination is authoritative. While a buyer's hold +(§ 5.6.2) runs, the seller MUST NOT decline that buyer's `commitment` for lack of +availability. This does not cover inventory that a hold granted ahead of it +(§ 5.6.2) reserves or took on converting. A firm entry limits a +decline to the grounds in § 4.3.2. Acceptance ends the availability check; booking +is downstream (§ 1.1). Frozen when the line item's `status` becomes `agreed`. + +**`CommitmentAmount`** is the committed figure. `basis` means only this. + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `basis` | Enum | REQUIRED | `spend` \| `units` | +| `amount` | Number | REQUIRED | The figure committed | + +**`Commitment`** holds the keys of `CommitmentAmount`, each `settable`, and these: + +| Key | Type | Required | Meaning | Mutability | +| :--- | :--- | :--- | :--- | :--- | +| `proposal_id` | String | REQUIRED | The proposal the commitment is made against | settable | +| `version` | Integer | REQUIRED | The proposal `version` the commitment was priced against | settable | +| `currency` | String | REQUIRED when `basis` is `spend` | ISO-4217 | settable | +| `flight` | Interval | REQUIRED | The buyer's campaign. **Distinct from `offer_window`** | settable | +| `advertisers[]` | Array[Party] | OPTIONAL | Whom the spend is for | settable | +| `derived_units` | Integer | OPTIONAL | Computed from `amount`, `agreed_rate` and `cost_method`. Informative, not binding | derived | +| `idempotency_key` | String | REQUIRED | Buyer-generated, unique per commitment attempt | settable | + +An `idempotency_key` is opaque and scoped to one buyer and seller pair. The +seller MUST NOT interpret it. + +The seller MUST reject a new commitment attempt that is stale (§ 4.1.3), whose +proposal is past `offer_window.end`, or where the `pricing[]` entry the commitment is priced against +is past its `price_valid_until`. The rejection MUST carry `stale_version`, or +`expired` when `offer_window.end` or `price_valid_until` has passed, so the buyer can +fetch it again. These checks MUST be deterministic: they depend only on the +stored proposal and the time the seller receives the attempt. A seller that +rejects an attempt before `offer_window.start` returns `not_committable` with +`retry: same`. + +For a replay from the same buyer of a commitment attempt the seller has +already processed, with the same `idempotency_key` and the same body (every +value the buyer sent with the attempt except `idempotency_key`), the seller +MUST return the original result, whether an acceptance or a rejection, and +MUST NOT create a second commitment. The staleness and expiry checks apply to a +new attempt, not to returning a stored result. An error whose `retry` is `same` +is not a stored result, so the retry runs the checks again. Reusing an +`idempotency_key` with a different body MUST return `idempotency_conflict` and +MUST NOT create or modify a commitment. A retry against a new `version` is a +new attempt, with a new `idempotency_key`. #### 5.6.2 hold_status -| Key | Meaning | -| :--- | :--- | -| `hold_available` | Whether this line item may be held | -| `hold_duration` | ISO-8601 duration the hold runs for | -| `hold_scope` | `line_item` \| `proposal` | -| `state` | `none` \| `requested` \| `held` \| `expired` \| `released` \| `converted` | -| `hold_expires_at` | Absolute timestamp, set when the hold is granted | +**`HoldStatus`** keys: + +| Key | Type | Required | Meaning | Mutability | +| :--- | :--- | :--- | :--- | :--- | +| `hold_available` | Boolean | OPTIONAL | Whether this line item may be held | seller-set | +| `hold_duration` | Duration | OPTIONAL | How long the hold runs | seller-set | +| `hold_scope` | Enum | OPTIONAL | `line_item` \| `proposal` | seller-set | +| `state` | Enum | OPTIONAL | `none` \| `requested` \| `held` \| `expired` \| `released` \| `converted`, moved as below | lifecycle | +| `hold_expires_at` | Instant | OPTIONAL | Set when the hold is granted | lifecycle | Transitions: `none` → `requested` → `held` → (`expired` | `released` | `converted`). `converted` is terminal and occurs when the line item reaches `agreed`. +#### 5.6.3 requires + +**`Requires`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `all_of[]` | Array[String] | OPTIONAL | Line items that MUST all be in the same `commit` | +| `any_of[]` | Array[String] | OPTIONAL | Line items of which at least one MUST be in the same `commit` | +| `min_commitments[]` | Array[MinCommitment] | OPTIONAL | Purchase-level thresholds: `{ line_item_id, basis, amount, currency }` | + +```yaml +requires: + all_of: ["business-daily-display", "weather-app-ctv"] + min_commitments: + - { line_item_id: "business-daily-display", basis: spend, amount: 50000.00, currency: USD } +``` + +A bonus line item is a line item with `requires` and its own pricing, such as +a discounted `list_rate` or `cost_method: FLAT` at 0. A conditional discount +is modelled as a separate line item that `requires` unlocks, not as a price +that changes according to the other line items selected. Every price remains +self-contained. + +- References in `requires` MUST point to line items in the same proposal and + version. +- A seller MUST reject a `commit` that includes a line item whose `requires` + is unmet. An unmet `requires` returns `not_committable`. This check MUST be + deterministic. +- If a line item referenced in `requires` becomes `sold_out` or `withdrawn` + before a `commit`, and the line items left can't meet `requires`, the dependent + line item becomes non-committable. After `agreed`, cancellation of a referenced + line item is governed by the dependent line item's `cancellation_policy`. + ### 5.7 Guarantees -| 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. Nothing in effect: no provider | +| `shortfall_remedy` | Selectable[VocabularyTerm] | OPTIONAL | selectable | Core terms: `makegood` \| `credit` \| `none`, plus `remedy_terms` | +| `reporting` | Selectable[ReportingOption] | OPTIONAL | selectable | See `ReportingOption` below. Nothing in effect: no report is promised | + +**`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` | +| `id` | String | REQUIRED | Option id (§ 3.1) | +| `vendor` | String | REQUIRED | The measurement provider | +| `attribution_window` | Duration | OPTIONAL | How long after exposure the provider attributes an event | +| `qualifier` | String | OPTIONAL | A product or method of the provider | + +**`ReportingOption`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `id` | String | REQUIRED | Option id (§ 3.1) | +| `feed_type` | Enum | REQUIRED | `log_level` \| `aggregate` | +| `cadence` | TimeUnit | OPTIONAL | How often a report arrives | +| `delivery_mechanism` | VocabularyTerm | OPTIONAL | How a report arrives. Core terms: `api` \| `s3` | +| `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 | Mutability | Description | +`visibility` says who may read a line item. `required_credentials[]`, +`eligible_buyers[]`, `eligible_seats[]`, and `buyer_requirements` (§ 5.9) say who +may commit to it. + +| Field | Type | Required | Mutability | Description | +| :--- | :--- | :--- | :--- | :--- | +| `visibility` | Enum | REQUIRED | seller-set | `public` \| `registry_gated` \| `named`. See below | +| `required_credentials[]` | Array[RequiredCredential] | OPTIONAL | seller-set | What a buyer holds to be eligible | +| `eligible_buyers[]` | Array[RegistrableDomain] | OPTIONAL | seller-set | The buyers that may commit, by `buyer.domain` | +| `eligible_seats[]` | Array[SeatRef] | OPTIONAL | seller-set | The seats that may commit. An entry with an absent or empty `ids[]` names no seat and admits no one, unlike an absent OpenRTB 2.6 `Deal.wseat`, which sets no restriction | +| `terms_acceptance_required` | Boolean | OPTIONAL | seller-set | Whether legal terms must be accepted before assent | +| `terms_ref` | Object | REQUIRED when `terms_acceptance_required` is `true` | seller-set | `{ url, sha256, version }`: the legal terms or IO template the buyer accepts at assent. Hash-pinned, so assent (§ 4.1) binds to an exact document | + +`terms_ref` MUST be hash-pinned and frozen at `agreed`. + +`exclusions` (§ 5.5) reveal the buyer's strategy. A seller MUST NOT disclose a +buyer's `exclusions` to any party other than that buyer. + +**Who may read.** Each `visibility` value names who may read the line item: + +- `public`: any requester, with no authentication. +- `registry_gated`: an authenticated requester (§ 1.2) acting for a party the + IAB Tech Lab Agent Registry attests. +- `named`: an authenticated requester acting for a party that `eligible_buyers[]` + names, or that holds a seat `eligible_seats[]` names. + +How a seller learns which party a requester acts for, or that it holds a seat, is +authorization (§ 1.2), and the object doesn't carry it. + +An authenticated requester acting for the party a proposal's `buyer` names may read all +of it, whatever `visibility` says. Any other requester may read a proposal's own fields +only when it may read one of its line items. A response carries only the line items the +requester may read, and a request for any other line item, or for a proposal the +requester may not read, returns `not_found` (§ 2.4). A full fetch leaves out only line +items (§ 2.1), so a value it returns can name a line item the requester may not read, +such as a line item's `requires` (§ 5.6.3) or a `negotiation_history[]` path. + +**Who may commit.** A buyer is eligible when each of these that is present holds: + +- `eligible_buyers[]`, `eligible_seats[]`: `buyer.domain` MUST be in + `eligible_buyers[]`, or `buyer_requirements.seats` MUST name at least one seat, each + in an `eligible_seats[]` entry for its `dsp`. +- `buyer_requirements.allowed_dsps[]` (§ 5.9): when `buyer_requirements.seats` is + present, its `dsp` MUST be in the list. +- `required_credentials[]`: the buyer meets it. + +A buyer that fails these rules, or an authority check the seller applies (whether the +sender may make this request for `buyer`, or `buyer` for an advertiser it names, § 1.2), +gets `not_eligible` (§ 2.4), or `not_found` when the requester may not read what the request +names. That is a rejection, not a decline, so the limits on a decline (§ 4.3.2, +§ 5.6.1) do not apply. A mandate is not a field of this object. + +**`RequiredCredential`** keys: + +| 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 | +| `credential_type` | VocabularyTerm | REQUIRED | What the buyer holds. Core term: `iab_registry`, an entry in the IAB Tech Lab Agent Registry | +| `issuer` | Domain | REQUIRED | Who issues it | + +A consumer that does not know a `credential_type` MUST NOT treat the proposal or +line item as invalid for that reason. It MUST treat that entry as unsatisfiable. + +Agent identity and request authentication are defined by the AAMP agent identity +and signing profile (provisional name). They are not expressed through +`required_credentials[]`. ### 5.9 Execution -| Field | Type | 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. `max_select: 1` | +| `serving_mode` | VocabularyTerm | OPTIONAL | seller-set | Core terms: `client_side` \| `ssai` \| `dai` | +| `deal_id_issuance` | String | OPTIONAL | derived | When and how the deal id is minted. Issued on acceptance of the commitment (§ 5.6.1) | +| `buyer_requirements` | BuyerRequirements | OPTIONAL | mixed | See below | +| `serving_platform` | String | OPTIONAL | seller-set | Where the line item serves | + +**`TransactionMechanism`** is `direct_io` \| `programmatic_guaranteed` \| +`preferred_deal` \| `private_auction` \| `open_auction`. + +**`BuyerRequirements`** keys: + +| Key | Type | Required | Meaning | Mutability | +| :--- | :--- | :--- | :--- | :--- | +| `seats_expected` | Boolean | OPTIONAL | The seller asks the buyer to supply `seats` | seller-set | +| `allowed_dsps[]` | Array[RegistrableDomain] | OPTIONAL | The DSPs that may receive the deal (§ 5.8), each by its primary domain, as `SeatRef.dsp` (§ 2.2) | seller-set | +| `seats` | SeatRef | OPTIONAL | The buyer's seats on the deal, as the Deals API 1.0 `wseat` | supplied | +| `seat_owner` | Party | OPTIONAL | The legal entity that owns the seats. Absent: the `buyer` | supplied | ### 5.10 External references and settlement -| Field | Type | Mutability | Description | +| 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. `role` is a `VocabularyTerm`. Core terms: `io`, an IO id generated by an order management system and carried back on the line item; `agreement`, a buyer-held agreement id | +| `agreement` | Agreement | OPTIONAL | seller-set | The master or annual agreement the line item is priced under. An informative pointer; the agreement itself is outside this specification. A buyer-held agreement id travels as an `external_references[]` entry with `role: agreement` | +| `correlation_id` | String | OPTIONAL | derived | One identity for this line item's commitment, carried across every system it touches | +| `billing` | Billing | OPTIONAL | seller-set | Who delivers is not always who invoices | +| `supported_order_systems[]` | Array[String] | OPTIONAL | seller-set | Declared up front so the agent knows it will reconcile | + +**`Agreement`** keys: + +| Key | Type | Required | Meaning | +| :--- | :--- | :--- | :--- | +| `agreement_id` | String | REQUIRED | The agreement's id | +| `parties[]` | Array[Party] | OPTIONAL | The agreement's parties | +| `agreement_type` | VocabularyTerm | OPTIONAL | Core terms: `upfront` \| `annual` \| `master_services`. A receiver that doesn't know the term still reads the `agreement` | +| `term` | Interval | OPTIONAL | When the agreement runs | +| `document` | DocumentRef | OPTIONAL | The agreement, where the parties pin it | + +**`Billing`** keys: + +| Key | Type | Required | Meaning | | :--- | :--- | :--- | :--- | -| `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 | +| `delivering_party` | Party | OPTIONAL | Who delivers. Absent: the `seller` | +| `invoicing_party` | Party | OPTIONAL | Who invoices. Absent: the `seller` | +| `payment_terms` | String | OPTIONAL | The payment terms, such as `net 30` | +| `invoice_period` | VocabularyTerm | OPTIONAL | The invoice period. Core terms: `month` (default) \| `cycle` | +| `cycle_length` | Duration | REQUIRED when `invoice_period` is `cycle` | How long each `cycle` period runs, such as `P28D` (4 weeks) | +| `time_zone` | IanaZone | OPTIONAL | The clock for a `month` invoice period. Absent: `UTC` | + +A buyer's own code for a supplier goes in `external_references[]`. + +This specification does not define IO generation. IO creation is a connector +concern. OpenProposal supplies the agreed record, `terms_ref` and +`correlation_id` a connector needs. --- @@ -359,21 +1661,47 @@ Runnable copies of every example below live in [`examples/`](../examples/). ### 6.1 Proposal -See [`examples/proposal.yaml`](../examples/proposal.yaml). +Shows version 2 after one seller revision (§ 4.1.3). See +[`examples/proposal.yaml`](../examples/proposal.yaml). Its line items are +shown in their own files, §§ 6.2 to 6.5. ### 6.2 Display line item — fully worked -Shows `selectable` audiences with per-option pricing, a catalog reference, a -buyer commitment, and `settable` bounds. See +Shows `selectable` audiences with per-option pricing and `settable` bounds. See [`examples/line-item-display.yaml`](../examples/line-item-display.yaml). ### 6.3 CTV line item See [`examples/line-item-ctv.yaml`](../examples/line-item-ctv.yaml). +Inspecting this line item's VAST creative is subject to § 5.4.1. + ### 6.4 Audio line item -See [`examples/line-item-audio.yaml`](../examples/line-item-audio.yaml). +Shows a line item priced by market. See +[`examples/line-item-audio.yaml`](../examples/line-item-audio.yaml). + +### 6.5 Bonus line item + +A CTV line item unlocked by buying the display and CTV line items together, +priced on its own. See +[`examples/line-item-ctv-bonus.yaml`](../examples/line-item-ctv-bonus.yaml). + +### 6.6 Publication manifest + +A manifest (§ 2.3) listing the public line items of the example proposal. The +display line item is `registry_gated`, so it is not listed. See +[`examples/manifest.json`](../examples/manifest.json). + +### 6.12 Issued proposal, partly agreed + +A proposal issued to one buyer from the shared proposal of § 6.1 (§ 4.3). One line +item is agreed at version 1, with a commitment. Its buyer writes `selected[]` and +`value`, and `agreed_rate` follows from the options in effect (§ 5.6). The other stays +`published` at version 2, with a hold, so the proposal stays `published` (§ 4.3.3). The +`assent[]` entries name version 1, and the digest is over that line item alone +(§ 4.1.2). See +[`examples/proposal-issued.yaml`](../examples/proposal-issued.yaml). --- @@ -382,20 +1710,41 @@ See [`examples/line-item-audio.yaml`](../examples/line-item-audio.yaml). - **No inheritance mechanism to specify or debug.** Fields live at exactly one level, so there is no "overridable unless restated" rule to maintain. - **The proposal reads as a pitch, not a schema dump.** `description`, - `specifications`, `best_for` and `seasonality_notes` are what a model needs to + `specifications`, `best_for[]` and `seasonality_notes` are what a model needs to decide whether to look further. -- **Every line item is independently transactable, in exactly one channel.** A - buying agent can hand a single line item to a downstream system without - resolving proposal-level defaults or cross-channel ambiguity. -- **Channel support cost one new field and one generalisation** — `channel`, and - `committed_metrics[]` as tuples — rather than a schema fork per channel. The - format registry, not OpenProposal, carries what is channel-specific about - creative shape. +- **Every line item states its own terms, in exactly one channel.** A + buying agent can hand a line item, with the proposal's identity, to a downstream + system, with no proposal-level defaults or cross-channel ambiguity to resolve. +- **Channels share one object, not a schema fork per channel.** `channel` is the + discriminator, and `committed_metrics[]` names the metric in each tuple. The + format registry, through `specs`, carries what is channel-specific about creative + shape. - **Composition resolves without a round trip.** Because the seller declares the option set and its pricing up front, a buyer agent performs constraint satisfaction rather than negotiation. Only genuinely out-of-envelope asks require the counterparty to respond. +**Templates and issued proposals (non-normative).** + +- **Template:** seller-side only. It lives in the seller's own systems (its + proposal store), is not part of the public object, and is never transacted + directly. +- **Issued proposal:** what a buyer commits against (§ 4.3). Each has its own + `proposal_id` per counterparty or destination, and may carry + `proposal_template_id` (§ 4.1) to trace it back to its template and `issued_from` + to trace it back to a shared proposal. +- Buyer-specific terms, such as annual rates and named visibility, live on the + issued proposal's line items: `visibility: named`, `eligible_buyers[]` and + their own `pricing[]`. + +**Relationships, agreements and IOs (non-normative).** Buyer–seller +relationships are expressed as issued proposals (`visibility: named`, +`eligible_buyers[]` set to that buyer, with the negotiated `pricing[]`) plus +the buyer agent's business rules. "Preferred partner" weighting is a buyer-side +ranking rule, not part of this specification. `agreement` and `terms_ref` +(§ 5.10, § 5.8) point at the agreement and terms a line item is executed under; +IO generation happens through connectors. + --- ## 8. Open questions @@ -406,8 +1755,9 @@ see [docs/open-questions.md](../docs/open-questions.md). 1. **Does per-field mutability belong in 3.0?** Sections 3 and 5 make composition part of the object. The alternative is a catalog format where a buyer selects whole line items and everything else is a round trip. -2. **Do seller catalogs (`catalog_ref`) belong in 3.0**, or should the object - stay fully inline until a later version? +2. **Is a fetchable reference to a seller catalog needed?** The object is fully + inline: a seller reuses catalog entries in its own systems, and a summary lists + what § 2.1 names. 3. **Cross-channel guarantee comparability is unsolved.** Once one line item guarantees `viewable_rate` and another `completion_rate`, there is no normalised way for a buying agent to compare or optimise across them. Likely @@ -415,95 +1765,117 @@ 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. +8. **How does a receiver read a document from a later release?** `spec_version` + names the release a document follows, and § 2.5 makes a key the receiver's + release does not list invalid. Nothing says whether a receiver rejects a release + it does not know, or keeps and ignores keys it does not list. +11. **Must a manifest response carry both `ETag` and `Last-Modified`, or is either + enough?** § 2.3 requires at least one, so a buyer's client handles whichever the + seller sends. --- ## Appendix A — Field index -Every field in the specification, in document order, with its mutability -marker. Generated from the body of this document; **if the two disagree, the +Every field in the specification, in document order, with its type, presence, +and mutability marker. Generated from the body of this document; **if the two disagree, the body is normative.** -| Field | Section | Type | Marker | -| :--- | :--- | :--- | :--- | -| `proposal_id` | 4.1 Identity and lifecycle | String | immutable | -| `version` | 4.1 Identity and lifecycle | Integer | derived | -| `seller_id` | 4.1 Identity and lifecycle | String | immutable | -| `seller_name` | 4.1 Identity and lifecycle | String | immutable | -| `type` | 4.1 Identity and lifecycle | Enum | immutable | -| `status` | 4.1 Identity and lifecycle | Enum | derived | -| `assent` | 4.1 Identity and lifecycle | Object | derived | -| `valid_from` · `valid_until` | 4.1 Identity and lifecycle | Timestamp | seller-set | -| `brief_ref` | 4.1 Identity and lifecycle | String | supplied | -| `negotiation_history[]` | 4.1 Identity and lifecycle | Array[Object] | derived | -| `description` | 4.2 Agent comprehension | String | seller-set | -| `specifications` | 4.2 Agent comprehension | String/Object | seller-set | -| `best_for[]` | 4.2 Agent comprehension | Array[String] | seller-set | -| `not_suitable_for[]` | 4.2 Agent comprehension | Array[String] | seller-set | -| `seasonality_notes` | 4.2 Agent comprehension | String | seller-set | -| `line_item_id` | 5.1 Identity | String | immutable | -| `status` | 5.1 Identity | Enum | derived | -| `channel` | 5.1 Identity | Enum | immutable | -| `properties` | 5.2 Inventory composition | Object | selectable | -| `content_detail` | 5.2 Inventory composition | Object | seller-set | -| `environments` | 5.2 Inventory composition | Object | selectable | -| `audiences` | 5.3 Audience reach | Object | selectable | -| `addressable_scale` | 5.3 Audience reach | Integer | derived | -| `activation_route` | 5.3 Audience reach | Object | selectable | -| `buyer_supplied` | 5.3 Audience reach | Object | supplied | -| `formats` | 5.4 Creative requirements | Object | selectable | -| `specs` | 5.4 Creative requirements | Object | seller-set | -| `third_party_tags` | 5.4 Creative requirements | Object | seller-set | -| `creative_delivery` | 5.4 Creative requirements | Object | seller-set | -| `assets[]` | 5.4 Creative requirements | Array[Object] | supplied | -| `materials_due` | 5.4 Creative requirements | Timestamp/Offset | requestable | -| `approval` | 5.4 Creative requirements | Object | seller-set | -| `production` | 5.4 Creative requirements | Object | selectable | -| `geo` | 5.5 Targeting envelope | Object | settable | -| `device` | 5.5 Targeting envelope | Object | settable | -| `daypart` | 5.5 Targeting envelope | Object | settable | -| `frequency_cap` | 5.5 Targeting envelope | Object | settable | -| `pricing[]` | 5.6 Commercial terms | Array[Object] | mixed | -| `pricing[].cost_method` | 5.6 Commercial terms | Enum | seller-set | -| `pricing[].gross_rate` | 5.6 Commercial terms | Number | seller-set | -| `pricing[].agreed_rate` | 5.6 Commercial terms | Number | derived | -| `pricing[].seller_rate` | 5.6 Commercial terms | Number | seller-set | -| `pricing[].price_valid_until` | 5.6 Commercial terms | Timestamp | seller-set | -| `pricing[].currency` | 5.6 Commercial terms | String | seller-set | -| `pricing[].floor` | 5.6 Commercial terms | Number | seller-set | -| `availability` | 5.6 Commercial terms | Object | derived | -| `commitment_bounds` | 5.6 Commercial terms | Object | seller-set | -| `commitment` | 5.6 Commercial terms | Object | settable | -| `hold_status` | 5.6 Commercial terms | Object | requestable | -| `min_spend` | 5.6 Commercial terms | Number | seller-set | -| `cancellation_policy` | 5.6 Commercial terms | Object | selectable | -| `exclusivity` | 5.6 Commercial terms | Object | selectable | -| `creative_policy` | 5.6 Commercial terms | Object | seller-set | -| `committed_metrics[]` | 5.7 Guarantees | Array[Object] | requestable | -| `measurement_source` | 5.7 Guarantees | Object | selectable | -| `shortfall_remedy` | 5.7 Guarantees | Object | selectable | -| `reporting` | 5.7 Guarantees | Object | selectable | -| `visibility` | 5.8 Transaction eligibility | Enum | seller-set | -| `required_credentials[]` | 5.8 Transaction eligibility | Array[Object] | seller-set | -| `eligible_bidders[]` | 5.8 Transaction eligibility | Array[String] | seller-set | -| `terms_acceptance_required` | 5.8 Transaction eligibility | Boolean | seller-set | -| `buying_route` | 5.9 Execution | Enum | seller-set | -| `transaction_mechanism` | 5.9 Execution | Object | selectable | -| `serving_mode` | 5.9 Execution | Enum | seller-set | -| `deal_id_issuance` | 5.9 Execution | String | derived | -| `buyer_requirements` | 5.9 Execution | Object | mixed | -| `serving_platform` | 5.9 Execution | String | seller-set | -| `external_references[]` | 5.10 External references and settlement | Array[Object] | derived | -| `correlation_id` | 5.10 External references and settlement | String | derived | -| `billing` | 5.10 External references and settlement | Object | seller-set | -| `supported_order_systems[]` | 5.10 External references and settlement | Array[String] | seller-set | +| Field | Section | Type | Required | Marker | +| :--- | :--- | :--- | :--- | :--- | +| `proposal_id` | 4.1 Identity and lifecycle | String | REQUIRED | immutable | +| `version` | 4.1 Identity and lifecycle | Integer | REQUIRED | derived | +| `spec_version` | 4.1 Identity and lifecycle | String | REQUIRED | seller-set | +| `seller` | 4.1 Identity and lifecycle | Party | REQUIRED | immutable | +| `buyer` | 4.1 Identity and lifecycle | Party | REQUIRED when `type` is `custom` or `assent[]` is present | immutable | +| `type` | 4.1 Identity and lifecycle | Enum | REQUIRED | immutable | +| `proposal_template_id` | 4.1 Identity and lifecycle | String | OPTIONAL | immutable | +| `issued_from` | 4.1 Identity and lifecycle | VersionRef | OPTIONAL | immutable | +| `status` | 4.1 Identity and lifecycle | ProposalStatus | REQUIRED | derived | +| `record_digest` | 4.1 Identity and lifecycle | Digest | OPTIONAL | derived | +| `assent[]` | 4.1 Identity and lifecycle | Array[AssentEntry] | OPTIONAL | recorded | +| `offer_window` | 4.1 Identity and lifecycle | Interval | OPTIONAL | seller-set | +| `brief_id` | 4.1 Identity and lifecycle | String | REQUIRED when `type` is `custom` | immutable | +| `negotiation_history[]` | 4.1 Identity and lifecycle | Array[HistoryEntry] | REQUIRED when the current `version` is frozen (§ 4.1.3) | recorded | +| `line_items[]` | 4.1 Identity and lifecycle | Array[LineItem] | REQUIRED | seller-set | +| `linked_execution` | 4.1 Identity and lifecycle | Enum | OPTIONAL | seller-set | +| `ext` | 4.1 Identity and lifecycle | Ext | OPTIONAL | seller-set | +| `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 | LineItemStatus | REQUIRED | lifecycle | +| `channel` | 5.1 Identity | MediaChannel | REQUIRED | immutable | +| `ext` | 5.1 Identity | Ext | OPTIONAL | seller-set | +| `properties` | 5.2 Inventory composition | Selectable[PropertyOption] | OPTIONAL | selectable | +| `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[GeoCode] | OPTIONAL | settable | +| `markets` | 5.5 Targeting envelope | Selectable[GeoCode] | OPTIONAL | selectable | +| `device` | 5.5 Targeting envelope | Settable[Integer] | OPTIONAL | settable | +| `daypart` | 5.5 Targeting envelope | Settable[ClockWindow] | OPTIONAL | settable | +| `time_zone` | 5.5 Targeting envelope | ZoneName | REQUIRED when `daypart` or `frequency_cap` is present | seller-set | +| `frequency_cap` | 5.5 Targeting envelope | FrequencyCap | OPTIONAL | settable | +| `exclusions_accepted` | 5.5 Targeting envelope | Object | OPTIONAL | seller-set | +| `exclusions` | 5.5 Targeting envelope | Object | OPTIONAL | supplied | +| `pricing[]` | 5.6 Commercial terms | Array[PricingEntry] | REQUIRED | mixed | +| `pricing[].cost_method` | 5.6 Commercial terms | VocabularyTerm | REQUIRED | seller-set | +| `pricing[].list_rate` | 5.6 Commercial terms | Number | REQUIRED | requestable | +| `pricing[].agreed_rate` | 5.6 Commercial terms | Number | REQUIRED when this is the committed entry | derived | +| `pricing[].seller_rate` | 5.6 Commercial terms | Number | OPTIONAL | seller-set | +| `pricing[].price_valid_until` | 5.6 Commercial terms | Instant | OPTIONAL | seller-set | +| `pricing[].currency` | 5.6 Commercial terms | String | REQUIRED | seller-set | +| `pricing[].min_rate` | 5.6 Commercial terms | Number | OPTIONAL | seller-set | +| `availability` | 5.6 Commercial terms | Object | OPTIONAL | asserted | +| `commitment_bounds` | 5.6 Commercial terms | Object | OPTIONAL | requestable | +| `commitment` | 5.6 Commercial terms | Commitment | OPTIONAL | mixed | +| `hold_status` | 5.6 Commercial terms | HoldStatus | OPTIONAL | mixed | +| `min_spend` | 5.6 Commercial terms | Number | OPTIONAL | seller-set | +| `cancellation_policy` | 5.6 Commercial terms | Selectable[CancellationOption] | OPTIONAL | selectable | +| `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 | REQUIRED | seller-set | +| `required_credentials[]` | 5.8 Transaction eligibility | Array[RequiredCredential] | OPTIONAL | seller-set | +| `eligible_buyers[]` | 5.8 Transaction eligibility | Array[RegistrableDomain] | OPTIONAL | seller-set | +| `eligible_seats[]` | 5.8 Transaction eligibility | Array[SeatRef] | OPTIONAL | seller-set | +| `terms_acceptance_required` | 5.8 Transaction eligibility | Boolean | OPTIONAL | seller-set | +| `terms_ref` | 5.8 Transaction eligibility | Object | REQUIRED when `terms_acceptance_required` is `true` | seller-set | +| `buying_route` | 5.9 Execution | Enum | OPTIONAL | seller-set | +| `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 | +| `agreement` | 5.10 External references and settlement | Agreement | OPTIONAL | seller-set | +| `correlation_id` | 5.10 External references and settlement | String | OPTIONAL | derived | +| `billing` | 5.10 External references and settlement | Billing | OPTIONAL | seller-set | +| `supported_order_systems[]` | 5.10 External references and settlement | Array[String] | OPTIONAL | seller-set | --- @@ -511,22 +1883,27 @@ body is normative.** | Marker | Count | Meaning | Fields | | :--- | :--- | :--- | :--- | -| immutable | 6 | Set once at creation. Neither party may change it. | `proposal_id`, `seller_id`, `seller_name`, `type`, `line_item_id`, `channel` | -| seller-set | 29 | Seller sets and may revise while `draft` or `published`. | `valid_from` · `valid_until`, `description`, `specifications`, `best_for[]`, `not_suitable_for[]`, `seasonality_notes`, `content_detail`, `specs`, `third_party_tags`, `creative_delivery`, `approval`, `pricing[].cost_method`, `pricing[].gross_rate`, `pricing[].seller_rate`, `pricing[].price_valid_until`, `pricing[].currency`, `pricing[].floor`, `commitment_bounds`, `min_spend`, `creative_policy`, `visibility`, `required_credentials[]`, `eligible_bidders[]`, `terms_acceptance_required`, `buying_route`, `serving_mode`, `serving_platform`, `billing`, `supported_order_systems[]` | -| selectable | 12 | Buyer chooses from a declared set. Resolves without assent. | `properties`, `environments`, `audiences`, `activation_route`, `formats`, `production`, `cancellation_policy`, `exclusivity`, `measurement_source`, `shortfall_remedy`, `reporting`, `transaction_mechanism` | -| settable | 5 | Buyer sets a value within declared bounds. Resolves without assent. | `geo`, `device`, `daypart`, `frequency_cap`, `commitment` | -| supplied | 3 | Buyer provides data or an artifact. | `brief_ref`, `buyer_supplied`, `assets[]` | -| requestable | 3 | Buyer may ask outside the declared space. Requires seller assent. | `materials_due`, `hold_status`, `committed_metrics[]` | -| derived | 11 | Computed. Neither party sets it directly. | `version`, `status` (proposal), `assent`, `negotiation_history[]`, `status` (line item), `addressable_scale`, `pricing[].agreed_rate`, `availability`, `deal_id_issuance`, `external_references[]`, `correlation_id` | -| mixed | 2 | Sub-fields carry different markers; see the section. | `pricing[]`, `buyer_requirements` | +| immutable | 9 | Set once at creation. Neither party may change it. | `proposal_id`, `seller`, `buyer`, `type`, `proposal_template_id`, `issued_from`, `brief_id`, `line_item_id`, `channel` | +| seller-set | 38 | The seller sets it and MAY revise it while the `status` of the object that carries it is `draft`, `published` or `under_review`. § 4.3.2 states what a revision does to a line item `under_review`. The buyer cannot change it directly. | `spec_version`, `offer_window`, `line_items[]`, `linked_execution`, `ext` (proposal), `description`, `specifications`, `best_for[]`, `not_suitable_for[]`, `seasonality_notes`, `ext` (line item), `content_detail`, `specs`, `third_party_tags`, `creative_delivery`, `approval`, `time_zone`, `exclusions_accepted`, `pricing[].cost_method`, `pricing[].seller_rate`, `pricing[].price_valid_until`, `pricing[].currency`, `pricing[].min_rate`, `min_spend`, `creative_policy`, `requires`, `visibility`, `required_credentials[]`, `eligible_buyers[]`, `eligible_seats[]`, `terms_acceptance_required`, `terms_ref`, `buying_route`, `serving_mode`, `serving_platform`, `agreement`, `billing`, `supported_order_systems[]` | +| selectable | 13 | The buyer chooses from a seller-declared set. Each option MAY carry a price delta. Resolves without a round trip. | `properties`, `environments`, `audiences`, `activation_route`, `formats`, `production`, `markets`, `cancellation_policy`, `exclusivity`, `measurement_source`, `shortfall_remedy`, `reporting`, `transaction_mechanism` | +| settable | 4 | The buyer supplies a value within seller-declared bounds. Resolves without a round trip. | `geo`, `device`, `daypart`, `frequency_cap` | +| supplied | 1 | The buyer provides data or an artifact. The seller does not pre-populate it. | `exclusions` | +| requestable | 4 | The buyer MAY ask for a value outside the declared space. An ask changes the value only through a seller `grant` or `counter` (§ 4.3.1). A change on the seller's own initiative follows § 4.1.3. | `materials_due`, `pricing[].list_rate`, `commitment_bounds`, `committed_metrics[]` | +| derived | 6 | Computed by a rule this specification states. Neither party sets it directly. | `version`, `status` (proposal), `record_digest`, `pricing[].agreed_rate`, `deal_id_issuance`, `correlation_id` | +| lifecycle | 1 | Live state of the object. The party its section names moves it between the stated values. Not a term. | `status` (line item) | +| recorded | 3 | Written by the party that acted. Append-only: an entry is never edited or removed. Not a term. | `assent[]`, `negotiation_history[]`, `external_references[]` | +| asserted | 2 | The seller states it from data the buyer can't recompute. Not a term, and it changes without a new version. | `addressable_scale`, `availability` | +| mixed | 6 | The keys of the object carry different markers: in the Mutability column of its key table, in its lead-in for composed keys, or on its rows for `pricing[]`. | `buyer_supplied`, `assets[]`, `pricing[]`, `commitment`, `hold_status`, `buyer_requirements` | ### What the distribution says -Of 71 fields, **23 are touched by the buyer** — 12 `selectable`, 5 `settable`, 3 -`supplied` and 3 `requestable`. The remaining 48 are `seller-set`, `immutable`, -`derived` or `mixed`. +Of 87 fields, **22 are touched by the buyer** — 13 `selectable`, 4 `settable`, 1 +`supplied` and 4 `requestable`. The remaining 65 are `seller-set`, `immutable`, +`derived`, `lifecycle`, `recorded`, `asserted` or `mixed`. The buyer also writes some of +those: its own entries in `assent[]`, and its keys of `commitment`, `assets[]`, +`buyer_supplied`, and `buyer_requirements`. -Only the 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 4 `requestable` fields require a round trip to resolve (§ 5.6.1). +Everything else the buyer touches — 18 fields — resolves against bounds the seller +declared up front. That ratio is the design claim: composition is constraint +satisfaction, and negotiation is the exception.