diff --git a/.github/ISSUE_TEMPLATE/change-request.yml b/.github/ISSUE_TEMPLATE/change-request.yml index daef61d..0cbdab1 100644 --- a/.github/ISSUE_TEMPLATE/change-request.yml +++ b/.github/ISSUE_TEMPLATE/change-request.yml @@ -46,7 +46,7 @@ body: - "5.7 — Guarantees" - "5.8 — Transaction eligibility" - "5.9 — Execution" - - "5.10 — External references and settlement" + - "5.10 — External references, agreement, and billing" - "6 — Examples" - "Appendix A / B — Field index" - "Whole document / more than one section" diff --git a/.github/ISSUE_TEMPLATE/comment.yml b/.github/ISSUE_TEMPLATE/comment.yml index 2549c24..bf6de09 100644 --- a/.github/ISSUE_TEMPLATE/comment.yml +++ b/.github/ISSUE_TEMPLATE/comment.yml @@ -34,7 +34,7 @@ body: - "5.7 — Guarantees" - "5.8 — Transaction eligibility" - "5.9 — Execution" - - "5.10 — External references and settlement" + - "5.10 — External references, agreement, and billing" - "6 — Examples" - "7 — Why this split" - "8 — Open questions" @@ -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: @@ -63,9 +63,8 @@ body: A specific case where the draft as written produces the wrong outcome or cannot express what you need. This is what makes a comment actionable. placeholder: >- - A seller offering the same inventory as both PG and PMP cannot express a - different floor per mechanism, because pricing[] sits above - transaction_mechanism. + A [seller, buyer, or agent] doing [a specific case] can't express [what it + needs], because [the field or rule in the draft]. - type: dropdown id: perspective 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/ISSUE_TEMPLATE/editorial.yml b/.github/ISSUE_TEMPLATE/editorial.yml index 4531355..0620436 100644 --- a/.github/ISSUE_TEMPLATE/editorial.yml +++ b/.github/ISSUE_TEMPLATE/editorial.yml @@ -12,7 +12,7 @@ body: id: location attributes: label: Where - placeholder: "spec/openproposal-3.0.md § 5.6, Appendix A row for `hold_status`" + placeholder: "spec/openproposal-3.0.md § 5.6, Appendix A row for `hold_terms`" validations: required: true - type: textarea 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..3dc3db5 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -67,3 +67,24 @@ 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 + + pricing-fixtures: + name: Pricing arithmetic vectors + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python3 scripts/check_pricing_fixtures.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 920fa44..face7b4 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 | @@ -40,10 +40,8 @@ them, but the short version: - **Cite the section.** `§ 5.6.1 commitment`, not "the commitment bit". - **Say what breaks.** A concrete scenario — a seller, a buyer, a field, and the - wrong outcome — beats a general objection. "A seller offering the same - inventory as both PG and PMP cannot express different floors per mechanism, - because `pricing[]` sits above `transaction_mechanism`" is actionable. "The - pricing model is too simple" is not. + wrong outcome — beats a general objection such as "the pricing model is too + simple". - **Say what you'd do instead**, even roughly. A rejected alternative is more useful to the working group than an unanswered complaint. - **Declare your interest.** Tell us whether you are commenting as a buyer, 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..c94ae19 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,15 @@ 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. A `Line.productid` +would be one use: an agreed record doesn't say which OpenDirect `Product` it books. **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 +67,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 +92,87 @@ 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-9 — What basis is a money value on? + +**Issue:** [#30](https://github.com/IABTechLab/OpenProposal/issues/30), [#115](https://github.com/IABTechLab/OpenProposal/issues/115) · **Spec:** § 5.6 + +Every `Amount` on a line item is on one basis. The draft doesn't say whether that +basis is gross or net of agency commission, or whether it includes tax. A +`commitment.amount` of 80,000.00 at an `agreed_rate` of 24.00 `CPM` buys 3,333,333 +impressions if 24.00 is what the seller invoices. If 24.00 is gross of a 15% +commission, the seller invoices 20.40, and 80,000.00 stated net buys 3,921,568. + +**What would settle it:** how sellers and agencies state a rate today, and what the +invoice is expected to match. + +--- + +### OQ-10 — What settles a shortfall once no remedy remains? + +**Issue:** [#95](https://github.com/IABTechLab/OpenProposal/issues/95) · **Spec:** § 5.7.1 + +A buyer may refuse a sole or last `makegood`, and § 5.7.1 then points at `remedy_terms`, +whose keys describe only a makegood. The draft also doesn't state what a `credit` is +worth. Such a shortfall could settle under terms the seller states or fall back to a +credit, or a sole makegood within the flight could bind the buyer. + +**What would settle it:** what sellers and buyers do today when a makegood is refused, +and how they value a credit. + +--- + +### 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. + +--- + +### OQ-12 — How does a `change_requests[]` entry state a change other than a cancellation? + +**Issue:** [#68](https://github.com/IABTechLab/OpenProposal/issues/68) · **Spec:** § 4.4 + +`cancellation` states a notice to cancel. The OpenDirect 2.1 ChangeRequest has no +structured statement of any other change, only free-text `comments`. + +**What would settle it:** how sellers and buyers state a change today, and what a +system that applies one needs to read. + +--- + ## 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/proposals/pricing-commitment-arithmetic.md b/docs/proposals/pricing-commitment-arithmetic.md new file mode 100644 index 0000000..d9bcf27 --- /dev/null +++ b/docs/proposals/pricing-commitment-arithmetic.md @@ -0,0 +1,117 @@ +# Account for seller fees after selecting one price + +Discussion wording for [#94](https://github.com/IABTechLab/OpenProposal/issues/94) +and [#115](https://github.com/IABTechLab/OpenProposal/issues/115), first written +against draft `44dfaaf618736219426c311ec997b6d37e94b950`. The arithmetic also +exercises the rate derivation discussed in +[#30](https://github.com/IABTechLab/OpenProposal/issues/30). This is a discussion +profile, not an OpenProposal wire validator. The specification is normative, and +its § 3.1, § 5.6, and § 5.6.5 state the arithmetic. + +## The fee interpretation changes the available media spend + +#94 treats `pricing[]` entries as alternatives. #115 adds seller fees as extra +entries and includes them in `commitment.amount`. An extra alternative cannot +simultaneously be an additive fee. Summing all entries instead loses the choice +of cost method, currency and transaction mechanism. + +At USD 25 CPM, a USD 50,000 commitment containing a USD 2,500 production fee +leaves USD 47,500 for media: + +``` +media_spend = 50,000 - 2,500 = 47,500 USD +derived_units = 47,500 / 25 * 1,000 = 1,900,000 impressions +``` + +Two million impressions would cost USD 50,000 before the fee, or USD 52,500 +in total. Therefore #115's statement that `derived_units` is unaffected needs +revision if `amount` includes charges outside the unit rate. + +## Wording for sections 3.1, 5.6 and 5.6.5 + +The specification carries most of this wording. Three points stay proposals here: +that a cost already in the unit rate isn't charged again, the sign rules on the +agreed rate, and the limit on rounding unit estimates. + +`pricing[]` entries are alternative prices. A commitment MUST resolve to +exactly one entry. An implementation MUST NOT sum alternative +entries to compute the buyer's charge. + +A selected option's `delta_flat` is a one-time seller charge for that line +item commitment. It MUST NOT enter the unit rate or become another pricing +alternative. A seller fee MUST NOT be a `pricing[]` entry. A cost already +included in the unit rate MUST NOT also be added as a flat charge. Each selected option that is not +already included contributes its charge once. + +For `basis = spend`, `amount` is the seller total, on the same terms as +`agreed_rate`, including selected `delta_flat` charges (section 3.1). This does +not choose gross or net commission treatment, or decide the tax question in +#115. For a selected unit-rate method: + +``` +flat_charges = sum of applicable one-time seller charges +media_spend = commitment.amount - flat_charges +derived_units = floor(media_spend / agreed_rate * rate_per) +``` + +`rate_per` is 1,000 for CPM and CPMV, and 1 for CPC. For `basis = spend`, the +agreed rate MUST be positive. Flat charges MUST NOT exceed the spend commitment. +Zero remaining media spend yields zero units and still has to satisfy any +applicable minimum-unit requirement. + +For `basis = units`, the committed units remain binding. The agreed rate MAY +be zero for added-value units, and MUST NOT be negative: + +``` +media_spend = commitment.amount / rate_per * agreed_rate +seller_total = media_spend + flat_charges +``` + +`commitment_bounds.spend` applies to the seller total on the same basis as +`agreed_rate`. The selected entry's currency applies to rates, flat charges and +spend bounds. No currency conversion is implied. `pricing[].unit_bounds` counts the +selected entry's own unit. Rounding `derived_units` down MUST NOT change the committed +amount or authorize extra spend. + +## Arithmetic inputs do not establish acceptance + +The adapter consumes resolved, field-qualified adjustments that are not already +included. It starts with the chosen `list_rate`, adds applicable `delta_cpm`, multiplies +by `(1 + delta_fraction)` factors, and rounds once, half up, to 6 decimal places. +`0.20` means a 20 percent increase. Flat charges remain separate. + +A negotiated rate is the `list_rate` of the next `version` (§ 5.6), so the adapter has +no separate input for an accepted rate, and options in effect apply their deltas to +the granted `list_rate`. A `list_rate` of USD 21.50 with USD 8.50 in `delta_cpm` gives +USD 30, and a `delta_fraction` of `0.20` on top gives USD 36. + +The adapter does not evaluate `min_rate`, which is a hint (§ 5.6). Every successful +result carries `min_rate_check: not_checked`. A `min_rate` is checked only for numeric +shape and nonnegativity. In particular, a USD 19 `list_rate` against a USD 20 +`min_rate` remains USD 19 in the calculation. + +A successful calculation never authorizes a commitment, reservation or agreed order. + +## Scope and reproduction + +``` +python3 scripts/check_pricing_fixtures.py +python3 scripts/check_pricing_fixtures.py --json +``` + +[`fixtures/pricing/cases.json`](../../fixtures/pricing/cases.json) contains fixed +expected outcomes independent of the calculator. Decimal strings and rational +outputs avoid binary floating point rounding. `"77/2"` is an exact rate, not a +proposed JSON wire type. `derived_units` rounds down to a whole unit (§ 5.6.5). +Currency checks cover shape, not ISO-4217 membership. The caller supplies a +mechanism-eligible choice. + +The profile implements CPM, CPMV and CPC on a tax-exclusive basis only. That +fixture limit does not decide the normative tax basis. It rejects tax-inclusive +inputs, #103 print adjustments and flat credits. It cannot infer a mandatory fee +that is not represented by the resolved options. General fee objects, tax amounts, +invoice rounding and cost-method extensions need separate decisions. Unsupported +cases are not declarations that an OpenProposal document is invalid. + +The vectors test this adapter's arithmetic. They read neither the specification nor +the examples. `scripts/check_examples.py` recomputes the examples against the tables. 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..270b911 100644 --- a/examples/line-item-audio.yaml +++ b/examples/line-item-audio.yaml @@ -2,34 +2,73 @@ line_item_id: "streaming-audio" status: published channel: audio -environments: { available: ["audio_streaming"], included: ["audio_streaming"] } +placement: { feed: 1 } # Music streaming service (1) (§ 5.2.2) +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", built_by: seller, lead_time: "P14D" }, + { id: "buyer_supplied", built_by: 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, - 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 }] -buying_route: SSP_PLATFORM -transaction_mechanism: { available: ["PREFERRED_DEAL"], included: ["PREFERRED_DEAL"] } + - { pricing_id: "audio-cpm-usd", cost_method: CPM, list_rate: 18.00, min_rate: 15.84, + price_valid_until: "2026-12-15T23:59:59Z", currency: USD, + price_type: 3 } # Fixed Price + +commitment_bounds: { spend: { min: 5000.00 } } + +cancellation_policy: + available: + - id: "stepped-fee" + notice_duration: "P0D" + max_share: 1 + fees: + - { before_reference: "P60D", calculation: none } + - { before_reference: "P30D", calculation: cancelled, share: 0.25 } + - { before_reference: "P7D", calculation: cancelled, share: 0.50 } + - { before_reference: "P0D", calculation: cancelled, share: 1.00 } + included: ["stepped-fee"] + max_select: 1 + +exclusivity: + available: + - id: "auto-pod" + scope: category + categories: { taxonomy: 8, ids: ["1551"] } # Ad Product Taxonomy 2.0: Vehicles + competitors: ["competitor-auto.example"] + separation: pod + - { id: "sole-stream", scope: sole, separation: inventory } + included: [] # no exclusivity in the base rate + deltas: { "auto-pod": { delta_cpm: 4.00 }, "sole-stream": { delta_flat: 25000.00 } } + max_select: 1 + +committed_metrics: [{ metric: completion_rate, level: 0.92, direction: at_least }] +measurement_source: + available: [{ id: seller-measured, measured_by: seller }] + included: ["seller-measured"] +controlling_measurement: + available: [{ id: seller-count, counted_by: seller, + discrepancy_threshold: 0.10, final_after: "P30D" }] + included: ["seller-count"] + max_select: 1 +visibility: public +transaction_mechanism: + available: [{ id: preferred_deal, delivery_type: non_guaranteed }] + included: ["preferred_deal"] + max_select: 1 serving_mode: dai -correlation_id: "ep-q4-2026-commitment/streaming-audio" +deal_origin: "ssp-a.example" diff --git a/examples/line-item-ctv-bonus.yaml b/examples/line-item-ctv-bonus.yaml new file mode 100644 index 0000000..705c350 --- /dev/null +++ b/examples/line-item-ctv-bonus.yaml @@ -0,0 +1,37 @@ +# 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 on display, in the currency it +# commits. 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"] } +placement: { plcmt: 1, ssai: 3 } # Instream (1); all server-side (3) (§ 5.2.2) +formats: { available: ["ctv/instream-15s"], included: ["ctv/instream-15s"] } +creative_delivery: { method: seller_hosted, protocols: [13], secure_required: true } # VAST 4.2 (13) + +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 } + +pricing: + - { pricing_id: "ctv-bonus-flat-usd", cost_method: FLAT, list_rate: 0.00, + price_valid_until: "2026-12-01T23:59:59Z", currency: USD } + +cancellation_policy: + available: + - { id: "notice-14d", notice_duration: "P14D", max_share: 1, fees: [{ calculation: none }] } + included: ["notice-14d"] + +visibility: registry_gated # its requires names the gated display line item + +transaction_mechanism: + available: [{ id: direct_io, delivery_type: non_guaranteed }] + included: ["direct_io"] + max_select: 1 +serving_mode: dai diff --git a/examples/line-item-ctv-live-series.yaml b/examples/line-item-ctv-live-series.yaml new file mode 100644 index 0000000..96415ac --- /dev/null +++ b/examples/line-item-ctv-live-series.yaml @@ -0,0 +1,99 @@ +# OpenProposal 3.0 — Example: Live event series line item (spec § 6.11) +# Illustrative only. If the series ends in five games, Games 6 and 7 deliver +# nothing and a selected Game 7 delta_flat is not owed. The commitment still +# applies across the games that air (§ 5.2.9). +line_item_id: "playoffs-2026-ctv" +status: published +channel: ctv +properties: { available: [{ id: "sports-app" }], included: ["sports-app"] } +programs: # the content the line item serves within (§ 5.2.9) + available: + - id: "series-g1" + content: + series: "Playoffs 2026" + title: "Team A vs Team B, Game 1" + network: { id: "example-network", name: "Example Network" } + live: 1 # scheduled broadcast + realtime: 1 # happening in real time, so a replay does not match + schedule: { scheduledstart: "2026-12-01T00:00:00Z", scheduledend: "2026-12-01T03:00:00Z", + flexibleend: 1, eventstatus: 1 } # flexible end; scheduled + - id: "series-g2" + content: + series: "Playoffs 2026" + title: "Team A vs Team B, Game 2" + network: { id: "example-network", name: "Example Network" } + live: 1 # scheduled broadcast + realtime: 1 # happening in real time, so a replay does not match + schedule: { scheduledstart: "2026-12-03T00:00:00Z", scheduledend: "2026-12-03T03:00:00Z", + flexibleend: 1, eventstatus: 1 } # flexible end; scheduled + - id: "series-g3" + content: + series: "Playoffs 2026" + title: "Team A vs Team B, Game 3" + network: { id: "example-network", name: "Example Network" } + live: 1 # scheduled broadcast + realtime: 1 # happening in real time, so a replay does not match + schedule: { scheduledstart: "2026-12-05T00:00:00Z", scheduledend: "2026-12-05T03:00:00Z", + flexibleend: 1, eventstatus: 1 } # flexible end; scheduled + - id: "series-g4" + content: + series: "Playoffs 2026" + title: "Team A vs Team B, Game 4" + network: { id: "example-network", name: "Example Network" } + live: 1 # scheduled broadcast + realtime: 1 # happening in real time, so a replay does not match + schedule: { scheduledstart: "2026-12-07T00:00:00Z", scheduledend: "2026-12-07T03:00:00Z", + flexibleend: 1, eventstatus: 1 } # flexible end; scheduled + - id: "series-g5" + content: + series: "Playoffs 2026" + title: "Team A vs Team B, Game 5" + network: { id: "example-network", name: "Example Network" } + live: 1 # scheduled broadcast + realtime: 1 # happening in real time, so a replay does not match + schedule: { scheduledstart: "2026-12-09T00:00:00Z", scheduledend: "2026-12-09T03:00:00Z", + flexibleend: 1, eventstatus: 2 } # flexible end; tentative + - id: "series-g6" + content: + series: "Playoffs 2026" + title: "Team A vs Team B, Game 6" + network: { id: "example-network", name: "Example Network" } + live: 1 # scheduled broadcast + realtime: 1 # happening in real time, so a replay does not match + schedule: { scheduledstart: "2026-12-11T00:00:00Z", scheduledend: "2026-12-11T03:00:00Z", + flexibleend: 1, eventstatus: 2 } # flexible end; tentative + - id: "series-g7" + content: + series: "Playoffs 2026" + title: "Team A vs Team B, Game 7" + network: { id: "example-network", name: "Example Network" } + live: 1 # scheduled broadcast + realtime: 1 # happening in real time, so a replay does not match + schedule: { scheduledstart: "2026-12-13T00:00:00Z", scheduledend: "2026-12-13T03:00:00Z", + flexibleend: 1, eventstatus: 2 } # flexible end; tentative + included: ["series-g1", "series-g2", "series-g3", "series-g4"] + deltas: { "series-g7": { delta_flat: 5000.00 } } # in the committed pricing entry's currency + max_select: 7 +environments: { available: ["ctv"], included: ["ctv"] } +placement: { plcmt: 1, ssai: 3 } # Instream (1); all server-side (3) (§ 5.2.2) +formats: { available: ["ctv/instream-30s"], included: ["ctv/instream-30s"] } + +pricing: + - { pricing_id: "playoffs-cpm-usd", cost_method: CPM, list_rate: 60.00, price_type: 3, + price_valid_until: "2026-11-25T23:59:59Z", currency: USD } + +commitment_bounds: { spend: { min: 50000.00 } } +controlling_measurement: + available: [{ id: seller-count, counted_by: seller, + discrepancy_threshold: 0.10, final_after: "P30D" }] + included: ["seller-count"] + max_select: 1 + +shortfall_remedy: { available: ["makegood", "credit"], included: ["makegood"] } +visibility: public +transaction_mechanism: + available: [{ id: programmatic_guaranteed, delivery_type: guaranteed }] + included: ["programmatic_guaranteed"] + max_select: 1 +serving_mode: dai +deal_origin: "ssp-a.example" diff --git a/examples/line-item-ctv.yaml b/examples/line-item-ctv.yaml index fda3994..0093f95 100644 --- a/examples/line-item-ctv.yaml +++ b/examples/line-item-ctv.yaml @@ -4,32 +4,57 @@ status: published channel: ctv properties: { available: [{ id: "weather-app" }], included: ["weather-app"] } environments: { available: ["ctv"], included: ["ctv"] } +placement: { plcmt: 1, ssai: 3 } # Instream (1); all server-side (3) (§ 5.2.2) +break_position: # where in the stream, priced (§ 5.2.3) + available: + - { id: "midroll-any", delay: -1, slotinpod: 0, maxseq: 4 } # Generic Mid-Roll (-1), Any ad in the pod (0) + - { id: "preroll", delay: 0, slotinpod: 0 } # Pre-Roll (0), Any ad in the pod (0) + - { id: "first-in-pod", delay: -1, slotinpod: 1, maxseq: 4 } # Generic Mid-Roll (-1), First ad in the pod (1) + included: ["midroll-any"] + deltas: { "preroll": { delta_cpm: 4.00 }, "first-in-pod": { delta_cpm: 9.00 } } + max_select: 1 formats: { available: ["ctv/instream-15s", "ctv/instream-30s"], included: ["ctv/instream-15s"], - pricing: { "ctv/instream-30s": { delta_cpm: 8.00 } } } -creative_delivery: { method: seller_hosted, protocol: vast_4_2, secure_required: true } + deltas: { "ctv/instream-30s": { delta_cpm: 8.00 } } } +creative_delivery: { method: seller_hosted, protocols: [13], secure_required: true } # VAST 4.2 (13) -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, - price_valid_until: "2026-12-01T23:59:59Z", currency: USD } + - { pricing_id: "ctv-pa-cpm-usd", cost_method: CPM, list_rate: 45.00, min_rate: 39.60, + price_valid_until: "2026-12-01T23:59:59Z", currency: USD, + price_type: 1 } # First Price -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" +commitment_bounds: { spend: { min: 25000.00 } } -hold_status: { hold_available: true, hold_duration: "P14D", - hold_scope: line_item, state: held, - hold_expires_at: "2026-10-01T00:00:00Z" } +cancellation_policy: + available: + - { id: "notice-30d", notice_duration: "P30D", max_share: 1, fees: [{ calculation: none }] } + - { id: "notice-14d", notice_duration: "P14D", max_share: 1, fees: [{ calculation: none }] } + - { id: "notice-48h", notice_duration: "PT48H", max_share: 1, + fees: [{ calculation: cancelled, share: 0.10 }] } + included: ["notice-30d"] + deltas: { "notice-14d": { delta_fraction: 0.03 }, "notice-48h": { delta_fraction: 0.06 } } + max_select: 1 -committed_metrics: [{ metric: completion_rate, value: 0.95, basis: seller_reported }] -buying_route: SSP_PLATFORM -transaction_mechanism: { available: ["PRIVATE_AUCTION"], included: ["PRIVATE_AUCTION"] } -serving_mode: ssai -correlation_id: "ep-q4-2026-commitment/weather-app-ctv" +hold_terms: { available: false } # private_auction: no inventory reserved + +committed_metrics: [{ metric: completion_rate, level: 0.95, direction: at_least }] +measurement_source: + available: [{ id: seller-measured, measured_by: seller }] + included: ["seller-measured"] +controlling_measurement: + available: [{ id: seller-count, counted_by: seller, + discrepancy_threshold: 0.10, final_after: "P30D" }] + included: ["seller-count"] + max_select: 1 +visibility: public +transaction_mechanism: + available: [{ id: private_auction, delivery_type: non_guaranteed }] + included: ["private_auction"] + max_select: 1 +serving_mode: dai +deal_origin: "ssp-a.example" + +# 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..e6e48fe 100644 --- a/examples/line-item-display.yaml +++ b/examples/line-item-display.yaml @@ -1,6 +1,6 @@ # OpenProposal 3.0 — Example: Display line item, fully worked (spec § 6.2) -# Shows selectable audiences with per-option pricing, a catalog reference, -# a buyer commitment, and settable bounds. +# Shows selectable audiences with per-option pricing, pricing alternatives in two +# currencies, cancellation options, and settable bounds. line_item_id: "business-daily-display" status: published channel: display @@ -9,7 +9,7 @@ properties: available: [{ id: "business-daily", domains: ["business-daily.example"] }, { id: "markets-weekly", domains: ["markets-weekly.example"] }] included: ["business-daily"] - pricing: { markets-weekly: { delta_cpm: 2.00 } } + deltas: { markets-weekly: { delta_cpm: 2.00 } } max_select: 2 environments: @@ -18,84 +18,121 @@ 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 } } - -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 } + deltas: { "display/970x250": { delta_cpm: 3.00 } } + +creative_delivery: { method: buyer_hosted, other_protocols: [javascript_tag], secure_required: true } +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 - seller_rate: 20.40 - floor: 21.00 + - pricing_id: "bd-cpm-usd" + cost_method: CPM + list_rate: 24.00 + min_rate: 21.00 price_valid_until: "2026-11-15T23:59:59Z" currency: USD + price_type: 3 # Fixed Price + - pricing_id: "bd-cpm-eur" + cost_method: CPM + list_rate: 22.00 + price_valid_until: "2026-11-15T23:59:59Z" + currency: EUR + price_type: 3 # Fixed Price -commitment_bounds: { min_budget: 10000.00, max_budget: 500000.00 } +cancellation_policy: + available: + - { id: "notice-14d", notice_duration: "P14D", max_share: 1, fees: [{ calculation: none }] } + - { id: "notice-7d", notice_duration: "P7D", max_share: 1, fees: [{ calculation: none }] } + included: ["notice-14d"] + deltas: { "notice-7d": { delta_fraction: 0.02 } } + max_select: 1 -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" +commitment_bounds: { spend: { min: 10000.00, max: 500000.00 }, + earliest_start: "2026-10-05T00:00:00Z", latest_end: "2026-12-31T23:59:59Z" } -hold_status: { hold_available: true, hold_duration: "P7D", - hold_scope: line_item, state: none } +hold_terms: { available: true, duration: "P7D" } committed_metrics: - - { metric: viewable_rate, value: 0.70, basis: mrc } + - { metric: viewable_rate, level: 0.70, direction: at_least } + - { metric: ivt_rate, level: 0.05, direction: at_most } measurement_source: - available: [{ vendor: "IAS", attribution_window: "30d" }, - { vendor: "DoubleVerify", attribution_window: "30d" }] - included: ["IAS"] + available: [{ id: ias, measured_by: "integralads.com" }, + { id: dv, measured_by: "doubleverify.com" }] + included: ["ias"] + max_select: 1 +controlling_measurement: # whose count is invoiced + available: + - { id: seller-count, counted_by: seller, + discrepancy_threshold: 0.10, final_after: "P10D" } + - { id: buyer-3pas, counted_by: buyer, qualifier: "Buyer ad server A", + discrepancy_threshold: 0.10, final_after: "P15D" } + included: ["seller-count"] + max_select: 1 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, report_type: aggregate, cadence: day, delivery_method: api }, + { id: daily-log-level, report_type: log_level, cadence: day, delivery_method: 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 }] - -buying_route: PUBLISHER_AD_SERVER -transaction_mechanism: { available: ["PROGRAMMATIC_GUARANTEED", "PREFERRED_DEAL"], - included: ["PROGRAMMATIC_GUARANTEED"] } -serving_mode: client_side -buyer_requirements: { seat_required: true, dsp_allowed: ["dsp-a", "dsp-b"], - dsp_seat_id: "seat-99812", dsp_id: "dsp-a-prod", - seat_owner_entity: "Agency A" } - -correlation_id: "ep-q4-2026-commitment/business-daily-display" +required_credentials: [{ credential_type: iab_registry, issuer: "registry.iabtechlab.ai" }] +terms_refs: + - { url: "https://examplepub.example/legal/io-terms-2026.pdf", + digest: "sha256:83e44f6fa723c35fef1b3528b16cc13a8a19ee34a32b658f6d3cc243ce12592e", version: "2026.1" } + +transaction_mechanism: + available: + - { id: programmatic_guaranteed, delivery_type: guaranteed } + - { id: preferred_deal, delivery_type: non_guaranteed } + included: ["programmatic_guaranteed"] + max_select: 1 +deal_origin: "adserver.example" +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" } + +ext: + examplepub.example: { placement_code: "BD-ROS-300x250-Q4" } diff --git a/examples/line-item-dooh.yaml b/examples/line-item-dooh.yaml new file mode 100644 index 0000000..5dfe948 --- /dev/null +++ b/examples/line-item-dooh.yaml @@ -0,0 +1,67 @@ +# OpenProposal 3.0 — Example: DOOH line item (spec § 6.9) +# Illustrative only. Plays and impressions as in § 5.6.4. A buyer's composed record +# (§ 4.1.1) of a line item issued to it (§ 4.3), before its commit. +line_item_id: "metro-platforms-dooh" +status: published +channel: dooh +properties: + available: + - id: "metro-platforms-north" + name: "Metro platforms, north line" + domains: ["metro-media.example"] + venue_type: { taxonomy: 5, ids: ["10502"] } # 5: OpenOOH Venue Taxonomy 1.1; 10502: transit.subway.platform + screens: # abridged: 200 screens with these keys + - screen_id: "np-001" + lat: 40.7128 + lon: -74.0060 + time_zone: "America/New_York" + operating_hours: [{ days: [monday, tuesday, wednesday, thursday, friday, saturday, sunday], + start: "06:00", before: "24:00" }] + loop: { length_s: 60, slot_s: 10 } + slots_per_loop: 1 + included: ["metro-platforms-north"] +environments: { available: ["dooh"], included: ["dooh"] } +formats: { available: ["dooh/landscape-1920x1080-10s"], + included: ["dooh/landscape-1920x1080-10s"] } + +delivery_counting: + play_tolerance_ms: 500 + proof_of_performance: { logged_by: seller, evidence: ["play_log", "photo"] } + impression_multiplier: + impression_type: viewable + source_type: 1 # Measurement Vendor Provided + vendor: "audience-vendor.example" + dataset: "2026-R2" # the vendor's named release, with its reference period + settles_on: declared + +pricing: + - { pricing_id: "metro-cpm-usd", cost_method: CPM, list_rate: 8.00, agreed_rate: 8.00, currency: USD, price_type: 3, + price_valid_until: "2026-12-15T23:59:59Z" } + +commitment: + basis: spend + amount: 70156.80 + flight: { start: "2026-11-02T00:00:00Z", end: "2026-11-15T23:59:59Z" } + derived_units: 8769600 # impressions: 3,024,000 plays × 2.9 average + advertisers: [{ domain: "brand-a.example" }] + +controlling_measurement: + available: [{ id: vendor-count, counted_by: "audience-vendor.example", # MUST be the multiplier's source + discrepancy_threshold: 0.10, final_after: "P30D" }] + included: ["vendor-count"] + max_select: 1 +shortfall_remedy: + available: ["makegood"] + included: ["makegood"] + remedy_terms: { makegood_window: "P60D", makegood_type: same_unit } +time_zone: viewer_local # on dooh: each screen's time_zone +frequency_cap: { count: { min: 1, max: 60, default: 60 }, period: hour, per: screen } # plays per screen +visibility: named +eligible_buyers: ["agency-a.example"] +transaction_mechanism: + available: + - { id: direct_io, delivery_type: guaranteed } + - { id: programmatic_guaranteed, delivery_type: guaranteed } + included: ["direct_io"] + max_select: 1 +deal_origin: "ssp-a.example" diff --git a/examples/line-item-podcast-embedded.yaml b/examples/line-item-podcast-embedded.yaml new file mode 100644 index 0000000..f242f38 --- /dev/null +++ b/examples/line-item-podcast-embedded.yaml @@ -0,0 +1,40 @@ +# OpenProposal 3.0 — Example: Embedded podcast line item (spec § 6.10) +# Illustrative only. A host read recorded into the episodes released in the flight. +# A buyer's composed record (§ 4.1.1) of a line item issued to it (§ 4.3), before its +# commit. +line_item_id: "show-x-host-read-baked" +status: published +channel: audio +properties: { available: [{ id: "show-x", name: "Show X", domains: ["show-x.example"] }], + included: ["show-x"] } +placement: { feed: 3 } # Podcast (3) (§ 5.2.2) +episodes: new # required on a podcast feed (§ 5.2); new under embedded (§ 5.9) +formats: { available: ["audio/host-read-30s"], included: ["audio/host-read-30s"] } + +# no geo, device, or frequency_cap: everyone who downloads gets the read + +pricing: + - { pricing_id: "show-x-cpm-usd", cost_method: CPM, list_rate: 30.00, agreed_rate: 30.00, + price_valid_until: "2026-10-30T23:59:59Z", currency: USD, + unit_bounds: { min: 100000 } } + +controlling_measurement: # required under embedded; final_after ends post-flight counting (§ 5.9) + available: [{ id: seller-count, counted_by: seller, + discrepancy_threshold: 0.10, final_after: "P30D" }] + included: ["seller-count"] + max_select: 1 + +commitment: + basis: units + amount: 400000 # ad deliveries + flight: { start: "2026-11-02T00:00:00Z", end: "2026-11-30T23:59:59Z" } # first episode released to last episode released + advertisers: [{ domain: "brand-a.example" }] + +visibility: named +eligible_buyers: ["agency-a.example"] +transaction_mechanism: + available: [{ id: direct_io, delivery_type: guaranteed }] + included: ["direct_io"] + max_select: 1 +serving_mode: embedded +retention: { min: "P90D", max: "P1Y" } # stays 90 days at least, gone within a year diff --git a/examples/line-item-rewarded-video.yaml b/examples/line-item-rewarded-video.yaml new file mode 100644 index 0000000..cda5b83 --- /dev/null +++ b/examples/line-item-rewarded-video.yaml @@ -0,0 +1,48 @@ +# OpenProposal 3.0 — Example: Rewarded video line item (spec § 6.7) +# Illustrative only. In-app rewarded video across a pack of puzzle games. +line_item_id: "puzzle-games-rewarded" +status: published +channel: video +properties: { available: [{ id: "puzzle-pack", name: "Puzzle game pack" }], + included: ["puzzle-pack"] } +environments: { available: ["app_mobile"], included: ["app_mobile"] } +placement: # every impression of this line item (§ 5.2) + reward: 1 + instl: 1 + plcmt: 3 # Interstitial (3) + ssai: 1 # all client-side (1) + skip: 0 # watched to the end for the reward + playmethod: [3] # Initiates on Click with Sound On (3) + api: [6, 7] # MRAID 3.0 (6), OMID 1.0 (7) +formats: { available: ["video/30s"], included: ["video/30s"] } +creative_delivery: { method: buyer_hosted, protocols: [13], secure_required: true } # VAST 4.2 (13) + +frequency_cap: { count: { min: 1, max: 3, default: 2 }, period: day, per: device } +time_zone: viewer_local + +pricing: + - { pricing_id: "rewarded-cpm-usd", cost_method: CPM, list_rate: 22.00, min_rate: 19.50, price_type: 3, + price_valid_until: "2026-12-01T23:59:59Z", currency: USD } + +commitment_bounds: { spend: { min: 10000.00 } } + +committed_metrics: + - { metric: completion_rate, level: 0.95, direction: at_least } + - { metric: viewable_rate, level: 0.90, direction: at_least } +measurement_source: # third-party vendors run through OMID 1.0 (7) + available: [{ id: ias, measured_by: "integralads.com" }, + { id: dv, measured_by: "doubleverify.com" }] + included: ["ias"] + max_select: 1 +controlling_measurement: + available: [{ id: seller-count, counted_by: seller, + discrepancy_threshold: 0.10, final_after: "P30D" }] + included: ["seller-count"] + max_select: 1 +visibility: public + +transaction_mechanism: + available: [{ id: preferred_deal, delivery_type: non_guaranteed }] + included: ["preferred_deal"] + max_select: 1 +deal_origin: "ssp-a.example" diff --git a/examples/manifest.json b/examples/manifest.json new file mode 100644 index 0000000..955a465 --- /dev/null +++ b/examples/manifest.json @@ -0,0 +1,325 @@ +{ + "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" + ] + }, + "placement": { + "feed": 1 + }, + "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": [ + { + "pricing_id": "audio-cpm-usd", + "cost_method": "CPM", + "list_rate": 18.0, + "currency": "USD", + "price_type": 3 + } + ], + "commitment_bounds": { + "spend": { + "min": 5000.0 + } + }, + "committed_metrics": [ + { + "metric": "completion_rate", + "level": 0.92, + "direction": "at_least" + } + ], + "transaction_mechanism": { + "available": [ + { + "id": "preferred_deal", + "delivery_type": "non_guaranteed" + } + ] + }, + "has_requires": false + }, + { + "line_item_id": "playoffs-2026-ctv", + "channel": "ctv", + "environments": { + "available": [ + "ctv" + ] + }, + "programs": { + "available": [ + { + "id": "series-g1", + "content": { + "series": "Playoffs 2026", + "title": "Team A vs Team B, Game 1" + } + }, + { + "id": "series-g2", + "content": { + "series": "Playoffs 2026", + "title": "Team A vs Team B, Game 2" + } + }, + { + "id": "series-g3", + "content": { + "series": "Playoffs 2026", + "title": "Team A vs Team B, Game 3" + } + }, + { + "id": "series-g4", + "content": { + "series": "Playoffs 2026", + "title": "Team A vs Team B, Game 4" + } + }, + { + "id": "series-g5", + "content": { + "series": "Playoffs 2026", + "title": "Team A vs Team B, Game 5" + } + }, + { + "id": "series-g6", + "content": { + "series": "Playoffs 2026", + "title": "Team A vs Team B, Game 6" + } + }, + { + "id": "series-g7", + "content": { + "series": "Playoffs 2026", + "title": "Team A vs Team B, Game 7" + } + } + ] + }, + "placement": { + "plcmt": 1 + }, + "properties": { + "available": [ + { + "id": "sports-app" + } + ] + }, + "formats": { + "included": [ + "ctv/instream-30s" + ] + }, + "pricing": [ + { + "pricing_id": "playoffs-cpm-usd", + "cost_method": "CPM", + "list_rate": 60.0, + "currency": "USD", + "price_type": 3 + } + ], + "commitment_bounds": { + "spend": { + "min": 50000.0 + } + }, + "transaction_mechanism": { + "available": [ + { + "id": "programmatic_guaranteed", + "delivery_type": "guaranteed" + } + ] + }, + "has_requires": false + }, + { + "line_item_id": "weather-app-ctv", + "channel": "ctv", + "environments": { + "available": [ + "ctv" + ] + }, + "placement": { + "plcmt": 1 + }, + "break_position": { + "available": [ + { + "id": "midroll-any", + "delay": -1, + "slotinpod": 0 + }, + { + "id": "preroll", + "delay": 0, + "slotinpod": 0 + }, + { + "id": "first-in-pod", + "delay": -1, + "slotinpod": 1 + } + ] + }, + "properties": { + "available": [ + { + "id": "weather-app" + } + ] + }, + "formats": { + "included": [ + "ctv/instream-15s" + ] + }, + "pricing": [ + { + "pricing_id": "ctv-pa-cpm-usd", + "cost_method": "CPM", + "list_rate": 45.0, + "currency": "USD", + "price_type": 1 + } + ], + "commitment_bounds": { + "spend": { + "min": 25000.0 + } + }, + "committed_metrics": [ + { + "metric": "completion_rate", + "level": 0.95, + "direction": "at_least" + } + ], + "transaction_mechanism": { + "available": [ + { + "id": "private_auction", + "delivery_type": "non_guaranteed" + } + ] + }, + "has_requires": false + }, + { + "line_item_id": "puzzle-games-rewarded", + "channel": "video", + "environments": { + "available": [ + "app_mobile" + ] + }, + "placement": { + "reward": 1, + "instl": 1, + "plcmt": 3, + "skip": 0 + }, + "properties": { + "available": [ + { + "id": "puzzle-pack", + "name": "Puzzle game pack" + } + ] + }, + "formats": { + "included": [ + "video/30s" + ] + }, + "pricing": [ + { + "pricing_id": "rewarded-cpm-usd", + "cost_method": "CPM", + "list_rate": 22.0, + "currency": "USD", + "price_type": 3 + } + ], + "commitment_bounds": { + "spend": { + "min": 10000.0 + } + }, + "committed_metrics": [ + { + "metric": "completion_rate", + "level": 0.95, + "direction": "at_least" + }, + { + "metric": "viewable_rate", + "level": 0.9, + "direction": "at_least" + } + ], + "transaction_mechanism": { + "available": [ + { + "id": "preferred_deal", + "delivery_type": "non_guaranteed" + } + ] + }, + "has_requires": false + } + ] + } + ] +} diff --git a/examples/proposal-agreed.yaml b/examples/proposal-agreed.yaml new file mode 100644 index 0000000..9bc305a --- /dev/null +++ b/examples/proposal-agreed.yaml @@ -0,0 +1,148 @@ +# OpenProposal 3.0 — Example: Agreed proposal (spec § 6.8) +# Illustrative only. Seller names and rates are fictional. Both line items are +# agreed at version 2, after the seller countered a later materials_due. +# record_digest recomputes from this document under § 4.1.1, and it covers the +# digest of each document in terms_refs[], so the entries bind those exact bytes. +proposal_id: "examplepub-agencya-custom-q4-2026" +version: 2 +spec_version: "3.0" +seller: { domain: "examplepub.example", legal_name: "Example Publisher US", + registry_id: "reg:examplepub-digital" } +buyer: { domain: "agency-a.example", legal_name: "Agency A LLC" } +type: custom +brief_id: "agency-a-brief-q4-2026" +status: agreed # derived: no line item remains offered (§ 4.3.3) +offer_window: { start: "2026-09-14T00:00:00Z", end: "2026-12-31T23:59:59Z" } + +description: > + Display and CTV package for Agency A, built from its brief. + +assent: + - { role: commitment, version: 2, + record_digest: "sha256:b9557e0619f77d4f8a0f29845bd9a9be74b59d4e07e7e1f1826e223e236f92fa", + line_item_ids: ["business-daily-display", "weather-app-ctv"], + at: "2026-09-22T10:14:03Z", + idempotency_key: "agency-a-camp1-q4-2026-5d10e2c7" } + - { role: acceptance, version: 2, + record_digest: "sha256:b9557e0619f77d4f8a0f29845bd9a9be74b59d4e07e7e1f1826e223e236f92fa", + line_item_ids: ["business-daily-display", "weather-app-ctv"], + at: "2026-09-22T10:31:47Z" } + +negotiation_history: + - { version: 1, actor: seller, action: published, paths: [], + at: "2026-09-14T09:00:00Z" } + - { version: 1, actor: buyer, action: requested, + paths: [{ line_item_id: "business-daily-display", pointer: "/materials_due", + requested_value: { materials_due: { at: "2026-10-01T23:59:59Z" } } }], + at: "2026-09-14T09:02:11Z" } + - { version: 2, actor: seller, action: countered, # another value, version 2 + paths: [{ line_item_id: "business-daily-display", pointer: "/materials_due" }], + at: "2026-09-14T09:40:05Z" } + +# Changes requested after agreed. Outside record_digest; the terms stay as agreed. +# The first line item has an OpenDirect reservation. The second is a deal with no +# Order, so its entry has no Order keys. +change_requests: + - { id: "67853", + baseline: { proposal_id: "examplepub-agencya-custom-q4-2026", version: 2, + line_item_id: "business-daily-display" }, + actor: buyer, at: "2026-11-02T15:20:00Z", + accountid: "23873345", orderid: "345233", lineid: "778120", + requesterid: "7987654", status: PENDING, + comments: "Shorten the flight to end on 2026-12-07." } + - { id: "agency-a-change-0002", + baseline: { proposal_id: "examplepub-agencya-custom-q4-2026", version: 2, + line_item_id: "weather-app-ctv" }, + actor: buyer, at: "2026-11-03T09:05:00Z", + comments: "Shorten the flight to end on 2026-12-14." } + +line_items: + - line_item_id: "business-daily-display" + status: agreed + channel: display + properties: + available: [{ id: "business-daily", domains: ["business-daily.example"] }] + included: ["business-daily"] + formats: + available: ["display/300x250"] + included: ["display/300x250"] + creative_delivery: { method: buyer_hosted, other_protocols: [javascript_tag], secure_required: true } + materials_due: { at: "2026-09-28T23:59:59Z" } # version 1 had "2026-09-24T23:59:59Z" + pricing: + - { pricing_id: "bd-cpm-usd", cost_method: CPM, list_rate: 24.00, agreed_rate: 24.00, + min_rate: 21.00, price_valid_until: "2026-11-15T23:59:59Z", currency: USD, + price_type: 3 } + commitment_bounds: { spend: { min: 10000.00, max: 500000.00 }, + earliest_start: "2026-10-05T00:00:00Z", latest_end: "2026-12-31T23:59:59Z" } + controlling_measurement: + available: [{ id: seller-count, counted_by: seller, + discrepancy_threshold: 0.10, final_after: "P10D" }] + included: ["seller-count"] + max_select: 1 + commitment: + basis: spend + amount: 80000.00 + flight: { start: "2026-10-06T00:00:00Z", end: "2026-12-21T23:59:59Z" } + advertisers: [{ domain: "brand-a.example" }] + derived_units: 3333333 + hold_terms: { available: true, duration: "P7D" } + transaction_mechanism: + available: [{ id: programmatic_guaranteed, delivery_type: guaranteed }] + included: ["programmatic_guaranteed"] + max_select: 1 + deal_origin: "adserver.example" + buyer_requirements: + seats: { dsp: "dsp-a.example", ids: ["seat-99812", "seat-99813"] } # the buyer's write + visibility: named + eligible_buyers: ["agency-a.example"] + terms_refs: + - { url: "https://examplepub.example/legal/io-terms-2026.pdf", + digest: "sha256:83e44f6fa723c35fef1b3528b16cc13a8a19ee34a32b658f6d3cc243ce12592e", version: "2026.1" } + - { url: "https://examplepub.example/legal/agency-a-q4-addendum.pdf", + digest: "sha256:ab4cac3cb240b5c6eec3d0bf40e8c0b0f21c2026837865d88a5c941702f2eeca" } + external_references: + - { system: "orders.examplepub.example", id: "345233", reference_type: order } + - { system: "orders.examplepub.example", id: "778120", reference_type: line } + - { system: "adserver.example", id: "ep-agency-a-display-q4", reference_type: deal } + + - line_item_id: "weather-app-ctv" + status: agreed + channel: ctv + properties: { available: [{ id: "weather-app" }], included: ["weather-app"] } + placement: { plcmt: 1 } # Instream (1) (§ 5.2) + formats: + available: ["ctv/instream-15s"] + included: ["ctv/instream-15s"] + creative_delivery: { method: seller_hosted, protocols: [13], secure_required: true } # VAST 4.2 (13) + pricing: + - { pricing_id: "ctv-pa-cpm-usd", cost_method: CPM, list_rate: 45.00, agreed_rate: 45.00, min_rate: 39.60, + price_valid_until: "2026-12-01T23:59:59Z", currency: USD, + price_type: 1 } # First Price + commitment_bounds: { spend: { min: 25000.00 } } + controlling_measurement: + available: [{ id: seller-count, counted_by: seller, + discrepancy_threshold: 0.10, final_after: "P10D" }] + included: ["seller-count"] + max_select: 1 + commitment: + basis: spend + amount: 160000.00 + flight: { start: "2026-10-06T00:00:00Z", end: "2026-12-21T23:59:59Z" } + advertisers: [{ domain: "brand-a.example" }] + hold_terms: { available: false } # private_auction: no inventory reserved + transaction_mechanism: + available: [{ id: private_auction, delivery_type: non_guaranteed }] + included: ["private_auction"] + max_select: 1 + deal_origin: "ssp-a.example" + buyer_requirements: + seats: { dsp: "dsp-a.example", ids: ["seat-99812", "seat-99813"] } # the buyer's write + visibility: named + eligible_buyers: ["agency-a.example"] + terms_refs: + - { url: "https://examplepub.example/legal/io-terms-2026.pdf", + digest: "sha256:83e44f6fa723c35fef1b3528b16cc13a8a19ee34a32b658f6d3cc243ce12592e", version: "2026.1" } + - { url: "https://examplepub.example/legal/agency-a-q4-addendum.pdf", + digest: "sha256:ab4cac3cb240b5c6eec3d0bf40e8c0b0f21c2026837865d88a5c941702f2eeca" } + external_references: + - { system: "ssp-a.example", id: "ep-agency-a-ctv-q4", reference_type: deal } diff --git a/examples/proposal-issued.yaml b/examples/proposal-issued.yaml new file mode 100644 index 0000000..5671f6c --- /dev/null +++ b/examples/proposal-issued.yaml @@ -0,0 +1,168 @@ +# 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:7e73bd909e58c26626174ab2942a3e858f3b1c83c6d30e3c0fa16ca4ec34b720", + line_item_ids: ["business-daily-display"], + at: "2026-10-05T10:14:03Z", + idempotency_key: "agency-a-camp1-business-daily-display-8f42c1a9" } + - { role: acceptance, version: 1, + record_digest: "sha256:7e73bd909e58c26626174ab2942a3e858f3b1c83c6d30e3c0fa16ca4ec34b720", + line_item_ids: ["business-daily-display"], + 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" } + +# Outside record_digest; the terms stay as agreed. The buyer gives notice under the +# option in effect, so the effective date is the later of 2026-11-30 and 2026-10-22. +change_requests: + - { id: "agency-a-cancel-0001", + baseline: { proposal_id: "examplepub-agencya-q4-2026-omni", version: 1, + line_item_id: "business-daily-display" }, + actor: buyer, at: "2026-10-15T15:20:00Z", + accountid: "23873345", orderid: "345301", lineid: "778412", requesterid: "7987654", + cancellation: { option_id: "notice-7d", notice_received_at: "2026-10-15T15:20:00Z", + requested_effective_at: "2026-11-30T23:59:59Z", cancelled_share: 0.5 }, + comments: "Cancel half of what remains from the end of November." } + +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 + creative_delivery: { method: buyer_hosted, other_protocols: [javascript_tag], secure_required: true } + 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) + - { pricing_id: "bd-cpm-usd", cost_method: CPM, list_rate: 24.00, agreed_rate: 27.54, + price_valid_until: "2026-11-15T23:59:59Z", currency: USD, price_type: 3 } + - { pricing_id: "bd-cpm-eur", cost_method: CPM, list_rate: 22.00, + price_valid_until: "2026-11-15T23:59:59Z", currency: EUR, price_type: 3 } + commitment_bounds: { spend: { min: 10000.00, max: 500000.00 }, + earliest_start: "2026-10-05T00:00:00Z", latest_end: "2026-12-31T23:59:59Z" } + cancellation_policy: + available: + - { id: "notice-14d", notice_duration: "P14D", max_share: 1, fees: [{ calculation: none }] } + - { id: "notice-7d", notice_duration: "P7D", max_share: 1, fees: [{ calculation: none }] } + included: ["notice-14d"] + deltas: { "notice-7d": { delta_fraction: 0.02 } } + max_select: 1 + selected: ["notice-7d"] # the buyer's write + controlling_measurement: + available: [{ id: seller-count, counted_by: seller, + discrepancy_threshold: 0.10, final_after: "P10D" }] + included: ["seller-count"] + max_select: 1 + commitment: + pricing_id: "bd-cpm-usd" + basis: spend + amount: 80000.00 + 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 + hold_terms: { available: true, duration: "P7D" } + visibility: named + eligible_buyers: ["agency-a.example"] + transaction_mechanism: + available: [{ id: programmatic_guaranteed, delivery_type: guaranteed }] + included: ["programmatic_guaranteed"] + max_select: 1 + deal_origin: "adserver.example" + buyer_requirements: + seats_expected: true + allowed_dsps: ["dsp-a.example", "dsp-b.example"] + seats: { dsp: "dsp-a.example", ids: ["seat-99812", "seat-99813"] } # the buyer's write + seat_owner: { domain: "agency-a.example", legal_name: "Agency A LLC" } # the buyer's write + external_references: + - { system: "orders.examplepub.example", id: "345301", reference_type: order } + - { system: "orders.examplepub.example", id: "778412", reference_type: line } + - { system: "adserver.example", id: "ep-agency-a-bd-q4", reference_type: deal } + + - line_item_id: "weather-app-ctv" + status: published + channel: ctv + properties: { available: [{ id: "weather-app" }], included: ["weather-app"] } + placement: { plcmt: 1 } # Instream (1) (§ 5.2) + formats: + available: ["ctv/instream-15s"] + included: ["ctv/instream-15s"] + creative_delivery: { method: seller_hosted, protocols: [13], secure_required: true } # VAST 4.2 (13) + materials_due: { at: "2026-10-20T23:59:59Z" } # version 1 had "2026-10-13T23:59:59Z" + pricing: + - { pricing_id: "ctv-pg-cpm-usd", cost_method: CPM, list_rate: 45.00, + price_valid_until: "2026-12-01T23:59:59Z", currency: USD, + price_type: 3 } # Fixed Price + commitment_bounds: { spend: { min: 25000.00 } } + controlling_measurement: + available: [{ id: seller-count, counted_by: seller, + discrepancy_threshold: 0.10, final_after: "P10D" }] + included: ["seller-count"] + max_select: 1 + hold_terms: { available: true, duration: "P14D" } + hold: # granted on version 1, re-offered at version 2 + status: held + version: 2 + requested_terms: + commitment: + basis: spend + amount: 160000.00 + flight: { start: "2026-11-02T00:00:00Z", end: "2026-12-21T23:59:59Z" } + advertisers: [{ domain: "brand-a.example" }] + properties: { selected: ["weather-app"] } + held_availability: { unit: impressions, computed_at: "2026-10-06T16:20:00Z", + window: { start: "2026-11-02T00:00:00Z", end: "2026-12-21T23:59:59Z" }, + certainty: reservable, quantity: 3555555 } + position: 1 + valid_until: "2026-10-20T00:00:00Z" + transaction_mechanism: + available: [{ id: programmatic_guaranteed, delivery_type: guaranteed }] + included: ["programmatic_guaranteed"] + max_select: 1 + deal_origin: "ssp-a.example" + visibility: named + eligible_buyers: ["agency-a.example"] diff --git a/examples/proposal.yaml b/examples/proposal.yaml index 9b51484..6d1d312 100644 --- a/examples/proposal.yaml +++ b/examples/proposal.yaml @@ -1,13 +1,23 @@ # 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" }], + at: "2026-10-03T14:12:40Z" } description: > Cross-channel Example Publisher US package spanning display, in-stream video @@ -19,3 +29,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/fixtures/pricing/cases.json b/fixtures/pricing/cases.json new file mode 100644 index 0000000..a302deb --- /dev/null +++ b/fixtures/pricing/cases.json @@ -0,0 +1,1795 @@ +{ + "profile": "pricing-discussion-4", + "cases": [ + { + "id": "cpm-no-fees", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "derived_units": "2000000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "seller-fee-inside-spend", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "2500", + "media_spend": "47500", + "seller_total": "50000", + "derived_units": "1900000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "units-add-fee-to-total", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "units", + "amount": "2000000", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "2500", + "media_spend": "50000", + "seller_total": "52500", + "derived_units": "2000000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "units-total-exceeds-spend-bound", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "units", + "amount": "2000000", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ], + "commitment_bounds": { + "spend": { + "max": "50000" + } + } + }, + "expected": { + "error": "outside_spend_bounds" + } + }, + { + "id": "spend-fee-does-not-increase-total", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ], + "commitment_bounds": { + "spend": { + "min": "50000", + "max": "50000" + } + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "2500", + "media_spend": "47500", + "seller_total": "50000", + "derived_units": "1900000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "fees-exceed-spend", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "2499", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "error": "fees_exceed_spend" + } + }, + { + "id": "fees-equal-spend-zero-media", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "2500", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "2500", + "media_spend": "0", + "seller_total": "2500", + "derived_units": "0", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "two-fees-count-once", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + }, + { + "id": "data/package", + "delta_flat": "500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "3000", + "media_spend": "47000", + "seller_total": "50000", + "derived_units": "1880000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "duplicate-adjustment-rejected", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + }, + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "error": "duplicate_adjustment" + } + }, + { + "id": "flat-credit-out-of-scope", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [ + { + "id": "rebate", + "delta_flat": "-1" + } + ] + }, + "expected": { + "error": "unsupported_flat_credit" + } + }, + { + "id": "three-rate-deltas", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "30", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "38500", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "77/2", + "flat_charges": "0", + "media_spend": "38500", + "seller_total": "38500", + "derived_units": "1000000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "granted-list-rate-takes-option-deltas", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "21.5", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "30000", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "30", + "flat_charges": "0", + "media_spend": "30000", + "seller_total": "30000", + "derived_units": "1000000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "granted-list-rate-keeps-flat-fees", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "21.5", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "32500", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + }, + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "30", + "flat_charges": "2500", + "media_spend": "30000", + "seller_total": "32500", + "derived_units": "1000000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "granted-list-rate-below-min-rate", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "19", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "30000", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "19", + "flat_charges": "0", + "media_spend": "30000", + "seller_total": "30000", + "derived_units": "1578947", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "granted-list-rate-at-min-rate", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "20", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "30000", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "20", + "flat_charges": "0", + "media_spend": "30000", + "seller_total": "30000", + "derived_units": "1500000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "fraction-after-cpm-delta", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "36000", + "adjustments": [ + { + "id": "format", + "delta_cpm": "5" + }, + { + "id": "rights", + "delta_fraction": "0.20" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "36", + "flat_charges": "0", + "media_spend": "36000", + "seller_total": "36000", + "derived_units": "1000000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "fractions-compound-and-exclude-flat", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "29500", + "adjustments": [ + { + "id": "rights", + "delta_fraction": "0.20" + }, + { + "id": "discount", + "delta_fraction": "-0.10" + }, + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "27", + "flat_charges": "2500", + "media_spend": "27000", + "seller_total": "29500", + "derived_units": "1000000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "delta-fraction-minus-one", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [ + { + "id": "discount", + "delta_fraction": "-1" + } + ] + }, + "expected": { + "error": "invalid_delta_fraction" + } + }, + { + "id": "delta-fraction-applies-to-granted-list-rate", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "21.5", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "36000", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + }, + { + "id": "rights", + "delta_fraction": "0.20" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "36", + "flat_charges": "0", + "media_spend": "36000", + "seller_total": "36000", + "derived_units": "1000000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "alternatives-require-choice", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + }, + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "list_rate": "2", + "currency": "USD" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [] + }, + "expected": { + "error": "pricing_choice_required" + } + }, + { + "id": "choose-cpc-without-summing-cpm", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + }, + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "list_rate": "2", + "currency": "USD" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ], + "pricing_id": "cpc-usd" + }, + "expected": { + "result": { + "pricing_id": "cpc-usd", + "currency": "USD", + "agreed_rate": "2", + "flat_charges": "2500", + "media_spend": "47500", + "seller_total": "50000", + "derived_units": "23750", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "choose-cpm-without-summing-cpc", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + }, + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "list_rate": "2", + "currency": "USD" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [], + "pricing_id": "cpm-usd" + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "derived_units": "2000000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "cpc-rejects-cpm-delta", + "input": { + "pricing": [ + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "list_rate": "2", + "currency": "USD" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + } + ] + }, + "expected": { + "error": "incompatible_delta_cpm" + } + }, + { + "id": "cpc-skips-delta-cpm-of-mixed-option", + "input": { + "pricing": [ + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "list_rate": "2", + "currency": "USD" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [ + { + "id": "rights", + "delta_cpm": "4", + "delta_fraction": "0.5" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpc-usd", + "currency": "USD", + "agreed_rate": "3", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "derived_units": "16666", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "cpc-unit-bounds-count-clicks", + "input": { + "pricing": [ + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "list_rate": "2", + "currency": "USD", + "unit_bounds": { + "min": "25001" + } + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [] + }, + "expected": { + "error": "outside_unit_bounds" + } + }, + { + "id": "duplicate-pricing-id", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + }, + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [], + "pricing_id": "cpm-usd" + }, + "expected": { + "error": "duplicate_pricing_id" + } + }, + { + "id": "unknown-pricing-id", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [], + "pricing_id": "absent" + }, + "expected": { + "error": "unknown_pricing_id" + } + }, + { + "id": "select-eur-alternative", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + }, + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "list_rate": "2", + "currency": "USD" + }, + { + "pricing_id": "cpm-eur", + "cost_method": "CPM", + "list_rate": "20", + "currency": "EUR" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [], + "pricing_id": "cpm-eur" + }, + "expected": { + "result": { + "pricing_id": "cpm-eur", + "currency": "EUR", + "agreed_rate": "20", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "derived_units": "2500000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "zero-rate", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "0", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [] + }, + "expected": { + "error": "nonpositive_rate" + } + }, + { + "id": "negative-rate", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "-1", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [] + }, + "expected": { + "error": "nonpositive_rate" + } + }, + { + "id": "zero-spend", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "0", + "adjustments": [] + }, + "expected": { + "error": "nonpositive_amount" + } + }, + { + "id": "negative-units", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "units", + "amount": "-1", + "adjustments": [] + }, + "expected": { + "error": "nonpositive_amount" + } + }, + { + "id": "float-not-fixture-decimal", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": 50000.0, + "adjustments": [] + }, + "expected": { + "error": "invalid_number" + } + }, + { + "id": "nonfinite-number", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "NaN", + "adjustments": [] + }, + "expected": { + "error": "invalid_number" + } + }, + { + "id": "exact-decimal-no-binary-rounding", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "0.1", + "currency": "USD", + "min_rate": "0" + } + ], + "basis": "spend", + "amount": "0.3", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "1/10", + "flat_charges": "0", + "media_spend": "3/10", + "seller_total": "3/10", + "derived_units": "3000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "derived-units-round-down", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "3", + "currency": "USD", + "min_rate": "0" + } + ], + "basis": "spend", + "amount": "1", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "3", + "flat_charges": "0", + "media_spend": "1", + "seller_total": "1", + "derived_units": "333", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "unit-minimum-after-fees", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20", + "unit_bounds": { + "min": "2000000" + } + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "error": "outside_unit_bounds" + } + }, + { + "id": "unit-maximum", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20", + "unit_bounds": { + "max": "1999999" + } + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [] + }, + "expected": { + "error": "outside_unit_bounds" + } + }, + { + "id": "inverted-spend-bounds", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [], + "commitment_bounds": { + "spend": { + "min": "60000", + "max": "50000" + } + } + }, + "expected": { + "error": "inverted_bounds" + } + }, + { + "id": "unknown-extension-is-not-implemented", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "seller.example:engagement", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [] + }, + "expected": { + "error": "unsupported_cost_method" + } + }, + { + "id": "flat-cost-method-is-not-a-flat-fee", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "FLAT", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [] + }, + "expected": { + "error": "unsupported_cost_method" + } + }, + { + "id": "tax-inclusive-needs-separate-rule", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [], + "tax_inclusive": true + }, + "expected": { + "error": "unsupported_field" + } + }, + { + "id": "print-adjustments-need-separate-rule", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [ + { + "id": "agency", + "value_pct": "-15" + } + ] + }, + "expected": { + "error": "unsupported_field" + } + }, + { + "id": "cpmv-uses-thousand-viewable-units", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPMV", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "derived_units": "2000000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "cpmv-unit-bounds-count-viewable-impressions", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPMV", + "list_rate": "25", + "currency": "USD", + "min_rate": "20", + "unit_bounds": { + "min": "2000000" + } + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "derived_units": "2000000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "min-rate-above-list-rate-changes-nothing", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "30" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "derived_units": "2000000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "negative-min-rate-is-malformed", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "-1" + } + ], + "basis": "spend", + "amount": "50000", + "adjustments": [] + }, + "expected": { + "error": "negative_min_rate" + } + }, + { + "id": "units-fee-inclusive-spend-min-11000", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "units", + "amount": "400000", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "commitment_bounds": { + "spend": { + "min": "11000" + } + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "1500", + "media_spend": "10000", + "seller_total": "11500", + "derived_units": "400000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "units-fee-inclusive-spend-min-11500", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "units", + "amount": "400000", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "commitment_bounds": { + "spend": { + "min": "11500" + } + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "1500", + "media_spend": "10000", + "seller_total": "11500", + "derived_units": "400000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "units-fee-inclusive-spend-min-12000", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "units", + "amount": "400000", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "commitment_bounds": { + "spend": { + "min": "12000" + } + } + }, + "expected": { + "error": "outside_spend_bounds" + } + }, + { + "id": "zero-rate-added-value-units-fee-0", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "0", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "units", + "amount": "400000", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "0", + "flat_charges": "0", + "media_spend": "0", + "seller_total": "0", + "derived_units": "400000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "zero-rate-added-value-units-fee-1500", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "0", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "units", + "amount": "400000", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "0", + "flat_charges": "1500", + "media_spend": "0", + "seller_total": "1500", + "derived_units": "400000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "negative-rate-units", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "-1", + "currency": "USD", + "min_rate": "20" + } + ], + "basis": "units", + "amount": "400000", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ] + }, + "expected": { + "error": "nonpositive_rate" + } + }, + { + "id": "lowercase-cost-method-is-unknown", + "input": { + "basis": "spend", + "amount": "50000", + "adjustments": [], + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "cpm", + "list_rate": "25", + "currency": "USD", + "min_rate": "20" + } + ] + }, + "expected": { + "error": "unsupported_cost_method" + } + }, + { + "id": "cpc-unit-bounds-at-derived-clicks", + "input": { + "basis": "spend", + "amount": "50000", + "adjustments": [], + "pricing": [ + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "list_rate": "2", + "currency": "USD", + "unit_bounds": { + "min": "25000" + } + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpc-usd", + "currency": "USD", + "agreed_rate": "2", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "derived_units": "25000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "unit-bounds-read-the-committed-entry-only", + "input": { + "basis": "spend", + "amount": "50000", + "adjustments": [], + "pricing_id": "cpc-usd", + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "unit_bounds": { + "min": "99999999" + } + }, + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "list_rate": "2", + "currency": "USD" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpc-usd", + "currency": "USD", + "agreed_rate": "2", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "derived_units": "25000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "inverted-unit-bounds", + "input": { + "basis": "spend", + "amount": "50000", + "adjustments": [], + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD", + "unit_bounds": { + "min": "2", + "max": "1" + } + } + ] + }, + "expected": { + "error": "inverted_bounds" + } + }, + { + "id": "empty-spend-bounds", + "input": { + "basis": "spend", + "amount": "50000", + "adjustments": [], + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "25", + "currency": "USD" + } + ], + "commitment_bounds": { + "spend": {} + } + }, + "expected": { + "error": "empty_bounds" + } + }, + { + "id": "agreed-rate-rounds-half-up", + "input": { + "basis": "spend", + "amount": "1.500002", + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "1.000001", + "currency": "USD" + } + ], + "adjustments": [ + { + "id": "rights", + "delta_fraction": "0.5" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "750001/500000", + "flat_charges": "0", + "media_spend": "750001/500000", + "seller_total": "750001/500000", + "derived_units": "1000", + "min_rate_check": "not_checked" + } + } + }, + { + "id": "agreed-rate-rounds-once", + "input": { + "basis": "spend", + "amount": "2.250002", + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "list_rate": "1.000001", + "currency": "USD" + } + ], + "adjustments": [ + { + "id": "rights", + "delta_fraction": "0.5" + }, + { + "id": "reach", + "delta_fraction": "0.5" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "1125001/500000", + "flat_charges": "0", + "media_spend": "1125001/500000", + "seller_total": "1125001/500000", + "derived_units": "1000", + "min_rate_check": "not_checked" + } + } + } + ] +} 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..983e024 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,239 @@ 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_SOURCE_LAYOUT = ("Key", "Type", "Required", "Meaning", "AdCOM 1.0, OpenRTB 2.6") +KEY_MUTABILITY_LAYOUT = ("Key", "Type", "Required", "Meaning", "Mutability") + +LAYOUTS = { + FIELD_LAYOUT, + KEY_LAYOUT, + KEY_SOURCE_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. + ("`transaction_mechanism`", "Downstream standard", "`reference_type` (§ 5.10)", "Change path"), + ("`transaction_mechanism`", "Deal mechanism", "`delivery_type`"), + ("`metric`", "Kind", "Usual `direction`", "Definition"), + ("`method`", "Who hosts the creative", "What reaches the seller"), + ("`code`", "Returned when"), + ("`channel`", "Condition"), + ("`environments`", "Distribution channel", "Device types"), + ("`cost_method`", "`unit`", "One unit", "Rate per", "`cost_parameters`"), + # Other tables state rules as prose does (section 2.6). + ("OpenDirect 2.1", "From the agreed line item"), + ("Layer", "Question", "Answered by", "Owner", "In the proposal object"), + ("Deals API 1.0", "OpenRTB 2.6", "From the agreed line item"), + ("Interaction", "Actor", "Effect; history `action`"), + ("Instant", "Ends", "Effect when passed"), + ("Rule", "Profile choice"), + # 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 = { + "PropertyOption": "the property option", + "AudienceOption": "the audience option", + "BuyerSupplied": "the buyer-supplied audience data", +} + +# 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 = { + "`specs`": "waiting: the format spec", + "`approval`": "waiting: the review policy", + "`exclusions_accepted`": "waiting: the accepted exclusion terms", + "`exclusions`": "waiting: the exclusion lists", + "`creative_policy`": "waiting: the creative policy", + "`contacts[]`": "imported: OpenDirect 2.1 Contact", + "`content`": "imported: AdCOM 1.0 Content", + "`providerdata`": "imported: OpenDirect 2.1 ChangeRequest", } -# 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_SOURCE_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_SOURCE_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 +275,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..c9b7159 --- /dev/null +++ b/scripts/check_examples.py @@ -0,0 +1,1274 @@ +#!/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. + - A commitment's arithmetic holds under section 5.6.5, on FLAT and TIME as well, + and it fits commitment_bounds.spend and the committed entry's unit_bounds. + - pricing_id is unique, two entries don't share a cost method, cost parameters, a + currency, and a mechanism, and the committed entry covers the mechanism in effect + (section 5.6). + - A Range's min is not greater than its max, and an Interval's end is later than its + start (section 2.2). + - Each mechanism option fits its row in section 5.9. deal_origin is on a deal line + item only, a deal reference names deal_origin (section 5.10), and availability is + forecast under non_guaranteed (section 5.6). + - A tentative event is outside included[] and priced only by delta_flat (section + 5.2.9), and an embedded line item follows section 5.9. + - The manifest is a strict path subset of the full objects (section 2.1), and lists + each public, published line item (section 2.3). + - seller and buyer on a proposal hold legal_name (section 4.1). + - Each section 4.1.1 removal path resolves to a table row. + - An array whose row says MUST be non-empty holds an entry. + - record_digest in examples/proposal-agreed.yaml and examples/proposal-issued.yaml + recomputes under section 4.1.1, over the line items each entry names, and a + buyer_supplied that holds no key after the removals moves no digest. + - In those examples a withdrawal or decline matches a commitment entry before it, + an acceptance matches a live commitment entry, and a line item is agreed only + with a matching pair (section 4.1.2). + - A change_requests[] baseline names an agreed line item at the version of its + acceptance entry (section 4.4). A cancellation names an option in effect, and its + cancelled_share is within the max_share of the range holding its effective date, or + the top-level one, and an option's dated_shares[] windows don't overlap (section 5.6.6). + - A core metric's level has the kind its row states, and counted_by equals the + party sections 5.6.4 and 5.7.1 name, under the Measurer comparison (section 2.2). + +KNOWN holds findings that a later change removes. It only shrinks. Run with +--regen-manifest to rewrite examples/manifest.json from the full examples. + +Run: python3 scripts/check_examples.py +""" +import argparse +import hashlib +import json +import re +import sys +from datetime import datetime, timedelta +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", "CostParameters"} +# A named type that holds the keys of another. +COMPOSES = {"Commitment": ["CommitmentAmount"], "MinCommitment": ["CommitmentAmount"], + "ManifestEntry": ["ProposalSummary"]} +# Named types that hold the keys of BuyerWrites (section 3.4), a path list. +WRITES_TYPES = {"LineItemWrites"} +# Named types that hold the keys of a Selectable[T], with their T, and add their own rows. +SELECTABLE_HOLDERS = {"ShortfallRemedy": "VocabularyTerm"} + +# (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), and the units they count. +IMPRESSION_METHODS = set() +IMPRESSION_UNITS = set() +# The mechanism table of section 5.9. +MECHANISMS = {} +# The cost table of section 5.6.5, as cost_method -> rate per, and -> unit terms. +RATE_PER = {} +COST_UNIT = {} +# The core metric table of section 5.7.1, as term -> Rate or Count. +METRIC_KIND = {} +# Fields absent when embedded (section 5.9). +EMBEDDED_ABSENT = [] +RATE_UNIT_SECONDS = {"hour": 3600, "day": 86400, "week": 604800} + + +def _options(item): + options = (item.get("transaction_mechanism") or {}).get("available", []) + return [o for o in options if isinstance(o, dict)] + + +def _mechanisms(item): + return {o.get("id") for o in _options(item)} + + +def _is_deal(mechanisms): + return any(MECHANISMS.get(m, {}).get("deal") for m in mechanisms) + + +def _counts_impressions(item): + # Section 5.6.4: any count on the line item is in impressions. + if any(p.get("cost_method") in IMPRESSION_METHODS for p in item.get("pricing", [])): + return True + if any(m.get("metric") == "impressions" for m in item.get("committed_metrics", [])): + return True + if (item.get("availability") or {}).get("unit") in IMPRESSION_UNITS: + return True + return False + + +def _has_sync_groups(item): + options = (item.get("properties") or {}).get("available", []) + return any("sync_groups" in o for o in options) + + +def _committed_entry(item): + """The pricing[] entry the line item's commitment is under, if any.""" + commitment, entries = item.get("commitment"), item.get("pricing") or [] + if not commitment: + return None + named = commitment.get("pricing_id") + if named is None: + return entries[0] if len(entries) == 1 else None + return next((e for e in entries if e.get("pricing_id") == named), None) + + +def _cost_method(parameters, item): + """The cost_method of the pricing[] entry that holds these cost_parameters.""" + for entry in item.get("pricing", []): + if entry.get("cost_parameters") is parameters: + return entry.get("cost_method") + return None + + +def applicable_max_share(terms, notice): + """Section 5.6.6: the range holding the effective date, or the top-level max_share.""" + parse = lambda s: datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ") + effective = parse(notice["requested_effective_at"]) + length = re.fullmatch(r"P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?", terms.get("notice_duration", "P0D")) + if length: # a length in months or years has no fixed span; the requested date stands + days, hours, minutes, seconds = (int(part or 0) for part in length.groups()) + earliest = parse(notice["notice_received_at"]) + timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds) + effective = max(effective, earliest) + for span in terms.get("dated_shares", []): + if parse(span["window"]["start"]) <= effective <= parse(span["window"]["end"]): + return span["max_share"] + return terms.get("max_share", 1) + + +def _has_order_reference(entry, proposal): + """Whether the line item a change_requests[] entry names has an order id.""" + named = (entry.get("baseline") or {}).get("line_item_id") + for item in (proposal or {}).get("line_items", []): + if item.get("line_item_id") == named: + refs = item.get("external_references", []) + return any(r.get("reference_type") == "order" for r in refs) + return False + + +# 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, + # Every VersionRef in the examples sits inside its proposal. + "the `VersionRef` is outside its proposal": lambda o, i, p: False, + "`role` is `decline`": lambda o, i, p: o.get("role") == "decline", + "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", + "`role` is `commitment`": lambda o, i, p: o.get("role") == "commitment", + "the line item has an `order` reference (§ 5.10)": + lambda o, i, p: _has_order_reference(o, p), + # No example holds a CommitResult. A ground of availability is the one sign of it. + "`entries[]` holds a `decline` for lack of availability": + lambda o, i, p: any(e.get("role") == "decline" and e.get("ground") == "availability" + for e in o.get("entries", [])), + "`cost_method` is an extension term": lambda o, i, p: ":" in str(o.get("cost_method")), + "the term requires keys": + lambda o, i, p: o.get("cost_method") in ("CPMV", "CPV", "CPP", "CPA", "TIME"), + "`pricing[]` has more than one entry": lambda o, i, p: len(i.get("pricing", [])) > 1, + "`cost_method` is `CPMV`": lambda o, i, p: _cost_method(o, i) == "CPMV", + "`cost_method` is `CPV` and `view_fraction` is absent": + lambda o, i, p: _cost_method(o, i) == "CPV" and "view_fraction" not in o, + "`cost_method` is `CPV` and `view_s` is absent": + lambda o, i, p: _cost_method(o, i) == "CPV" and "view_s" not in o, + "`cost_method` is `CPP`": lambda o, i, p: _cost_method(o, i) == "CPP", + "`cost_method` is `CPA`": lambda o, i, p: _cost_method(o, i) == "CPA", + "`cost_method` is `TIME`": lambda o, i, p: _cost_method(o, i) == "TIME", + "`calculation` is `cancelled` or `commitment`": + lambda o, i, p: o.get("calculation") in ("cancelled", "commitment"), + "`calculation` is `fixed`": lambda o, i, p: o.get("calculation") == "fixed", + "`scope` is `category`": lambda o, i, p: o.get("scope") == "category", + "`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, + "`status` is `held`": lambda o, i, p: o.get("status") == "held", + "an option's `delivery_type` is `share_of_voice`": + lambda o, i, p: any(x.get("delivery_type") == "share_of_voice" for x in _options(i)), + "`sov_basis` is `impressions`": lambda o, i, p: i.get("sov_basis") == "impressions", + "`channel` is `dooh`": lambda o, i, p: i.get("channel") == "dooh", + "`channel` is `audio`": lambda o, i, p: i.get("channel") == "audio", + "`channel` is `video`": lambda o, i, p: i.get("channel") == "video", + "`channel` is `video` or `audio`": lambda o, i, p: i.get("channel") in ("video", "audio"), + "`placement.feed` is Podcast (3) or Catch-up radio (4)": + lambda o, i, p: (i.get("placement") or {}).get("feed") in (3, 4), + "`serving_mode` is `embedded`": lambda o, i, p: i.get("serving_mode") == "embedded", + "the line item counts impressions": lambda o, i, p: _counts_impressions(i), + "`source_type` is not Publisher Provided (`2`)": lambda o, i, p: o.get("source_type") != 2, + "a property has `sync_groups[]`": lambda o, i, p: _has_sync_groups(i), + "`serving_mode` is `embedded` or the line item has `committed_metrics[]`, `commitment_bounds.spend`, or a `pricing[].unit_bounds`": + lambda o, i, p: i.get("serving_mode") == "embedded" + or "committed_metrics" in i or "spend" in (i.get("commitment_bounds") or {}) + or any("unit_bounds" in e for e in i.get("pricing", [])), + "the line item offers a deal mechanism": lambda o, i, p: _is_deal(_mechanisms(i)), + "the entry prices a deal mechanism": + lambda o, i, p: _is_deal(o.get("transaction_mechanisms") or _mechanisms(i)), +} + +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://"))), + "HttpsUrl": (lambda v: isinstance(v, str) and v.startswith("https://")), + "Integer": (lambda v: isinstance(v, int) and not isinstance(v, bool)), + "IntegerFlag": (lambda v: v in (0, 1) and not isinstance(v, bool)), + "Number": (lambda v: isinstance(v, (int, float)) and not isinstance(v, bool)), + "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"), + "HoldStatus": (lambda v: v in ("requested", "held", "declined", "expired", "released", + "converted")), + "ApprovalStatus": (lambda v: v in ("PENDING", "APPROVED", "REJECTED")), + "TransactionMechanism": (lambda v: v in MECHANISMS), + "DeliveryType": (lambda v: v in ("guaranteed", "share_of_voice", "non_guaranteed")), + "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"(? high: + self.err(file, path, "range", "min is greater than max") + return + if base == "Enum": + if not isinstance(value, str): + self.err(file, path, repr(value), f"{value!r} is not an Enum value") + return + if base == "Object": + if not isinstance(value, dict): + self.err(file, path, "object", f"expected an object, found {type(value).__name__}") + return + if base in SCALARS: + if not SCALARS[base](value): + self.err(file, path, repr(value), f"{value!r} is not a {base}") + return + if base == "Interval" and isinstance(value, dict): + starts, ends = value.get("start"), value.get("end") + if isinstance(starts, str) and isinstance(ends, str) and ends <= starts: + self.err(file, path, "interval", "an Interval's end is not later than its start") + if base in self.spec.tables: + self.check_object(file, path, value, base) + + def rows_of(self, name): + rows = dict(self.spec.tables.get(name, {})) + # A path list (section 2.1) gives its type the first segment of each path. The + # summary check below compares the values with the full objects. + heads = {"LineItemSummary": self.spec.summary_paths, + "ProposalSummary": self.spec.proposal_paths}.get(name, []) + for path in heads: + rows.setdefault(path.split(".")[0], {"type": "", "required": "OPTIONAL"}) + for extra in COMPOSES.get(name, []): + rows.update(self.rows_of(extra)) + return rows + + def check_object(self, file, path, value, name): + if not isinstance(value, dict): + self.err(file, path, "object", f"expected a {name}, found {type(value).__name__}") + return + known = {bare(k): r for k, r in self.rows_of(name).items()} + if name not in OPEN_TYPES: + for key in value: + if key in known: + continue + if name in WRITES_TYPES: + if not self.is_write(key): + self.err(file, path, key, f"key {key!r} is not a BuyerWrites path") + continue + self.err(file, path, key, f"key {key!r} is not in the {name} table") + for key, row in known.items(): + if key not in value and self.required(file, path, row["required"], value): + self.err(file, path, key, f"REQUIRED key {key!r} of {name} is missing") + if key in value: + self.check_value(file, f"{path}.{key}", value[key], row) + + def is_write(self, key): + """Section 3.4: whether a key heads a BuyerWrites path.""" + marker = self.spec.line_item.get(key, {}).get("marker") + if marker == "selectable" or (marker == "settable" and key != "frequency_cap"): + return True + return any(p.split(".")[0].removesuffix("[]") == key for p in self.spec.write_paths + if not p.startswith("<")) + + def check_selectable(self, file, path, value, option_type, extra=None): + if not isinstance(value, dict): + self.err(file, path, "selectable", "expected a Selectable") + return + if "available" not in value: + self.err(file, path, "available", "a Selectable holds available[]") + return + extra = {bare(k): r for k, r in (extra or {}).items()} + known = {bare(k) for k in self.spec.tables["Selectable"]} | set(extra) + for key in value: + if key not in known: + self.err(file, path, key, f"key {key!r} is not a Selectable key") + for key, row in extra.items(): + if key in value: + self.check_value(file, f"{path}.{key}", value[key], row) + names = [] + for i, option in enumerate(value["available"]): + if isinstance(option, dict): + if "id" not in option: + self.err(file, f"{path}.available[{i}]", "id", "an object option has an id") + names.append(option.get("id")) + if option_type in self.spec.tables: + self.check_object(file, f"{path}.available[{i}]", option, option_type) + else: + self.check_type(file, f"{path}.available[{i}]", option, option_type) + names.append(str(option)) + if len(names) != len(set(names)): + self.err(file, path, "names", "option names are not unique in the field") + if "included" not in value: + self.err(file, path, "included", "REQUIRED key 'included' is missing") + for name in value.get("included", []): + if name not in names: + self.err(file, f"{path}.included", name, f"{name!r} names no option") + for name in value.get("selected", []): + if name not in names: + self.err(file, f"{path}.selected", name, f"{name!r} names no option") + for name, delta in (value.get("deltas") or {}).items(): + if name not in names: + self.err(file, f"{path}.deltas", name, f"{name!r} names no option") + self.check_object(file, f"{path}.deltas.{name}", delta, "Delta") + if not delta: + self.err(file, f"{path}.deltas.{name}", "delta", "a Delta holds at least one key") + if "max_select" in value: + self.check_type(file, f"{path}.max_select", value["max_select"], "Integer") + field = path.rsplit(".", 1)[-1] + in_effect = value.get("selected", value.get("included", [])) + if "max_select" in value and len(in_effect) > value["max_select"]: + self.err(file, path, "max_select", "more options in effect than max_select") + if not in_effect and field not in EMPTY_ALLOWED: + self.err(file, path, "empty", f"{field} gives an empty set no meaning") + + def covers(self, field, outer, inner): + """Whether an entry of a settable field covers another (sections 3.2, 5.5).""" + if outer == inner: + return True + if field == "geo": + return str(inner).startswith(f"{outer}-") + if field == "device": + return outer == 1 and inner in (4, 5) + if field == "daypart": + return (set(inner.get("days", [])) <= set(outer.get("days", [])) + and outer.get("start", "") <= inner.get("start", "") + and inner.get("before", "") <= outer.get("before", "")) + return False + + def check_narrowing(self, file, path, value, item_type, allowed): + if not isinstance(value, dict) or not value.keys() <= {"include", "exclude"}: + self.err(file, path, "narrowing", "a Narrowing holds include[] and exclude[]") + return + if "include" not in value: + self.err(file, path, "include", "REQUIRED key 'include' is missing") + field = path.split(".")[-2] + include, exclude = value.get("include", []), value.get("exclude", []) + for key, within, entries in (("allowed", allowed, include), ("include", include, exclude)): + for entry in entries: + self.check_type(file, path, entry, item_type) + if not any(self.covers(field, outer, entry) for outer in within): + self.err(file, path, "covered", f"{entry!r} is not covered by {key}") + + def check_settable(self, file, path, value, item_type): + """Section 3.2: the envelope form, allowed[] with a default and the buyer's value.""" + if not isinstance(value, dict) or not value.keys() <= {"allowed", "default", "value"}: + self.err(file, path, "settable", "a Settable holds allowed[], default, and value") + return + for key in ("allowed", "default"): + if key not in value: + self.err(file, path, key, f"REQUIRED key {key!r} is missing") + allowed = value.get("allowed", []) + for i, entry in enumerate(allowed): + self.check_type(file, f"{path}.allowed[{i}]", entry, item_type) + for key in ("default", "value"): + if key in value: + self.check_narrowing(file, f"{path}.{key}", value[key], item_type, allowed) + + def check_settable_range(self, file, path, value, item_type): + """Section 3.2: the range form, the keys of Range with a default and the buyer's value.""" + if not isinstance(value, dict) or not value.keys() <= {"min", "max", "default", "value"}: + self.err(file, path, "settable", "a SettableRange holds min, max, default, and value") + return + if "min" not in value and "max" not in value: + self.err(file, path, "bound", "a SettableRange holds at least one bound") + if "default" not in value: + self.err(file, path, "default", "REQUIRED key 'default' is missing") + for key in ("min", "max", "default", "value"): + if key in value: + self.check_type(file, f"{path}.{key}", value[key], item_type) + if "min" in value and "max" in value and value["min"] > value["max"]: + self.err(file, path, "bound", "min is greater than max") + for key in ("default", "value"): + if key in value and ((("min" in value) and value[key] < value["min"]) + or (("max" in value) and value[key] > value["max"])): + self.err(file, f"{path}.{key}", "bounds", f"{key} is outside the bounds") + + def check_agreed_rate(self, file, path, item): + """Recompute agreed_rate on the committed entry (section 5.6).""" + committed = _committed_entry(item) + for entry in item.get("pricing") or []: + if entry is not committed and "agreed_rate" in entry: + self.err(file, path, "agreed_rate", "agreed_rate on an uncommitted entry") + if committed is None or "agreed_rate" not in committed: + return + decimal = lambda n: Decimal(str(n)) + cpm, factor = Decimal(0), Decimal(1) + for value in item.values(): + if not isinstance(value, dict) or "available" not in value: + continue + included = value.get("included", []) + names = [n for n in value.get("selected", included) if n not in included] + for name in names: + delta = (value.get("deltas") or {}).get(name, {}) + if committed.get("cost_method") in IMPRESSION_METHODS: + cpm += decimal(delta.get("delta_cpm", 0)) + elif set(delta) == {"delta_cpm"}: + self.err(file, path, name, "an option priced only by delta_cpm on this entry") + factor *= 1 + decimal(delta.get("delta_fraction", 0)) + rate = (decimal(committed["list_rate"]) + cpm) * factor + rate = rate.quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP) + if rate != decimal(committed["agreed_rate"]): + self.err(file, path, "agreed_rate", f"agreed_rate recomputes to {rate}") + + def check_mechanisms(self, file, path, item): + """Sections 5.6, 5.9, and 5.10: the mechanism options and what they require.""" + for option in _options(item): + row = MECHANISMS.get(option.get("id")) + if row and row["delivery"] is not None and option.get("delivery_type") not in row["delivery"]: + self.err(file, path, option["id"], + f"{option['id']} does not allow delivery_type {option.get('delivery_type')!r}") + if "deal_origin" in item and not _is_deal(_mechanisms(item)): + self.err(file, path, "deal_origin", "deal_origin on a line item with no deal mechanism") + for ref in item.get("external_references") or []: + if ref.get("reference_type") == "deal" and ref.get("system") != item.get("deal_origin"): + self.err(file, path, "deal", "a deal reference has system other than deal_origin") + programs = item.get("programs") or {} + for option in programs.get("available", []): + if (option.get("schedule") or {}).get("eventstatus") == 2: + name = option.get("id") + if name in programs.get("included", []): + self.err(file, path, name, f"tentative {name} is in included[]") + if set((programs.get("deltas") or {}).get(name, {})) - {"delta_flat"}: + self.err(file, path, name, f"tentative {name} has a delta other than delta_flat") + if item.get("serving_mode") == "embedded": + if item.get("episodes", "new") != "new": + self.err(file, path, "episodes", "episodes on an embedded line item is new") + for key in EMBEDDED_ABSENT: + if key in item: + self.err(file, path, key, f"{key} on an embedded line item") + if item.get("time_zone") == "viewer_local": + self.err(file, path, "time_zone", "time_zone on an embedded line item is viewer_local") + if _mechanisms(item) != {"direct_io"}: + self.err(file, path, "transaction_mechanism", "an embedded line item offers only direct_io") + mechanism = item.get("transaction_mechanism") or {} + in_effect = mechanism.get("selected", mechanism.get("included", [])) + delivery = {o.get("delivery_type") for o in _options(item) if o.get("id") in in_effect} + hold = item.get("hold") or {} + for key, figures in (("availability", item.get("availability")), + ("hold.held_availability", hold.get("held_availability"))): + if figures and delivery == {"non_guaranteed"} and figures.get("certainty") != "forecast": + self.err(file, path, key, f"{key}.certainty is forecast when delivery_type is non_guaranteed") + committed = _committed_entry(item) + if committed and committed.get("transaction_mechanisms"): + for name in in_effect: + if name not in committed["transaction_mechanisms"]: + self.err(file, path, name, f"the committed entry does not price {name}") + + def check_commitment(self, file, path, item): + """A commitment's arithmetic and its bounds (sections 5.6.1 and 5.6.5).""" + for entry in item.get("pricing") or []: + if entry.get("cost_method") == "FLAT" and "unit_bounds" in entry: + self.err(file, path, "unit_bounds", "unit_bounds on a FLAT entry") + commitment, committed = item.get("commitment"), _committed_entry(item) + if not commitment or not committed or "agreed_rate" not in committed: + return + decimal = lambda n: Decimal(str(n)) + method, rate = committed.get("cost_method"), decimal(committed["agreed_rate"]) + basis, amount = commitment.get("basis"), decimal(commitment.get("amount", 0)) + charges = Decimal(0) + for value in item.values(): + if isinstance(value, dict) and "available" in value: + included = value.get("included", []) + names = [n for n in value.get("selected", included) if n not in included] + for name in names + value.get("excluded", []): + charges += decimal((value.get("deltas") or {}).get(name, {}).get("delta_flat", 0)) + flight = commitment.get("flight") or {} + starts, ends = flight.get("start"), flight.get("end") + total, units, elapsed = amount, None, None + if rate < 0: + self.err(file, path, "agreed_rate", "agreed_rate is negative") + rate_per = decimal(committed.get("rate_per", RATE_PER.get(method) or 1)) + size = RATE_UNIT_SECONDS.get((committed.get("cost_parameters") or {}).get("rate_unit")) + if method == "TIME" and starts and ends and size: + parse = lambda s: datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ") + elapsed = int(((parse(ends) - parse(starts)).total_seconds() + 1) // size) + if method == "FLAT": + if basis != "spend": + self.err(file, path, "basis", "a FLAT commitment has basis spend") + if amount != rate + charges: + self.err(file, path, "amount", f"a FLAT amount is {rate + charges}") + if "derived_units" in commitment: + self.err(file, path, "derived_units", "derived_units on a FLAT commitment") + elif elapsed is not None: + units, total = elapsed, elapsed * rate + charges + if amount != (elapsed if basis == "units" else total): + self.err(file, path, "amount", f"a TIME commitment is for {elapsed} units, {total} in all") + elif basis == "spend": + if charges > amount: + self.err(file, path, "amount", "delta_flat charges exceed the amount") + if rate > 0: + units = int((amount - charges) / rate * rate_per) + else: + self.err(file, path, "agreed_rate", "a spend commitment on this entry needs an agreed_rate above 0") + else: + total, units = amount / rate_per * rate + charges, amount + if units is not None and "derived_units" in commitment and commitment["derived_units"] != units: + self.err(file, path, "derived_units", f"derived_units recomputes to {units}") + spend = (item.get("commitment_bounds") or {}).get("spend") or {} + if ("min" in spend and total < decimal(spend["min"])) or ("max" in spend and total > decimal(spend["max"])): + self.err(file, path, "commitment_bounds", f"seller total {total} is outside spend") + unit_limits = committed.get("unit_bounds") or {} + if units is not None and (units < unit_limits.get("min", units) or units > unit_limits.get("max", units)): + self.err(file, path, "unit_bounds", f"{units} units are outside unit_bounds") + + def check_measurement(self, file, path, item): + """Metric kinds, and the counted_by values sections 5.6.4 and 5.7.1 fix (section 2.2).""" + for i, tuple_ in enumerate(item.get("committed_metrics") or []): + kind, value = METRIC_KIND.get(tuple_.get("metric")), tuple_.get("level") + if kind == "Rate" and not SCALARS["Fraction"](value): + self.err(file, f"{path}.committed_metrics[{i}]", "kind", f"{value!r} is not a rate") + if kind == "Count" and not SCALARS["Integer"](value): + self.err(file, f"{path}.committed_metrics[{i}]", "kind", f"{value!r} is not a count") + selection = item.get("controlling_measurement") or {} + counters = selection.get("available", []) + expected = {} + if item.get("channel") == "dooh" and _counts_impressions(item): + multiplier = (item.get("delivery_counting") or {}).get("impression_multiplier") or {} + seller_domain = ((self.proposal or {}).get("seller") or {}).get("domain") + vendor = multiplier.get("vendor") + own = multiplier.get("source_type") == 2 or (seller_domain is not None and vendor == seller_domain) + source = "seller" if own else vendor + expected = {c.get("id"): source for c in counters} + committed = _committed_entry(item) + if committed and committed.get("cost_method") == "CPP": + source = (committed.get("cost_parameters") or {}).get("rating_source") + for name in selection.get("selected", selection.get("included", [])): + expected[name] = source + for counter in counters: + name = counter.get("id") + if name in expected and counter.get("counted_by") != expected[name]: + self.err(file, path, name, f"counted_by of {name} is not {expected[name]!r}") + + def check_pricing_entries(self, file, path, item): + """Section 5.6: entry ids are unique, and two entries differ on a key or mechanism.""" + entries = item.get("pricing") or [] + ids = [e.get("pricing_id") for e in entries] + if len(ids) != len(set(ids)): + self.err(file, path, "pricing_id", "two pricing[] entries share a pricing_id") + seen = set() + for entry in entries: + for name in entry.get("transaction_mechanisms") or _mechanisms(item): + key = (entry.get("cost_method"), json.dumps(entry.get("cost_parameters"), sort_keys=True), + entry.get("currency"), name) + if key in seen: + self.err(file, path, "pricing", f"two pricing[] entries are the same on {name}") + seen.add(key) + + def check_dated_shares(self, file, path, item): + """Section 5.6.6: an option's dated_shares[] windows don't overlap.""" + for option in (item.get("cancellation_policy") or {}).get("available", []): + windows = sorted((r["window"]["start"], r["window"]["end"]) for r in option.get("dated_shares", [])) + for earlier, later in zip(windows, windows[1:]): + if later[0] <= earlier[1]: + self.err(file, path, "dated_shares", f"{option.get('id')}: dated_shares[] windows overlap") + + 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) + self.check_commitment(file, path or "(root)", doc) + self.check_mechanisms(file, path or "(root)", doc) + self.check_measurement(file, path or "(root)", doc) + self.check_pricing_entries(file, path or "(root)", doc) + self.check_dated_shares(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: + parts = [re.sub(r"\[\*\]$", "", p) for p in path.lstrip("$.").split(".")] + 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): + 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) + return + if not rest: + node.pop(head, None) + elif isinstance(node.get(head), dict): + remove_path(node[head], rest) + + +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: + remove_path(copy, path[2:].split(".")) + # Section 4.1.1: the one object the removals don't leave in place when it is empty. + for item in copy.get("line_items", []): + if item.get("buyer_supplied") == {}: + del item["buyer_supplied"] + return "sha256:" + hashlib.sha256(jcs(copy).encode("utf-8")).hexdigest() + + +def check_digest_ignores_empty_buyer_supplied(checker, doc, removal): + """Section 4.1.1: a removed buyer_supplied value moves no digest, and a kept one does.""" + item = doc["line_items"][0] + forms = [ + None, + {}, + {"match_rate_expected": 0.65}, + {"segments": [], "suppression_list": {}, "match_rate_expected": 0.65}, + ] + digests = set() + for form in forms: + copy = json.loads(json.dumps(doc)) + copy["line_items"][0].pop("buyer_supplied", None) + if form is not None: + copy["line_items"][0]["buyer_supplied"] = form + digests.add(record_digest(copy, removal, [item["line_item_id"]])) + if len(digests) != 1: + checker.errors.append("record_digest: an absent and an empty buyer_supplied give different digests") + copy = json.loads(json.dumps(doc)) + copy["line_items"][0]["buyer_supplied"] = {"suppression_allowed": True} + if record_digest(copy, removal, [item["line_item_id"]]) in digests: + checker.errors.append("record_digest: a buyer_supplied that keeps a term moved no digest") + + +def check_assent(checker, file, doc): + """Section 4.1.2: only a matching pair makes agreed, and an acceptance matches a live entry.""" + def key(entry): + return entry["version"], entry["record_digest"], frozenset(entry["line_item_ids"]) + + items = {i["line_item_id"]: i for i in doc.get("line_items", [])} + commitments, live, matched, agreed = [], set(), set(), set() + for entry in doc.get("assent", []): + role = entry["role"] + if "ground" in entry and role != "decline": + checker.errors.append(f"{file}: {role} entry carries ground") + for named in entry["line_item_ids"]: + if named not in items: + checker.errors.append(f"{file}: {role} entry names {named}, which is not a line item") + elif role == "commitment" and "commitment" not in items[named]: + checker.errors.append(f"{file}: commitment entry names {named}, which has no commitment") + if role == "commitment": + commitments.append(key(entry)) + live.add(key(entry)) + elif role == "acceptance": + # Section 4.1.2: an acceptance matches only a live commitment entry. + if key(entry) not in live: + checker.errors.append(f"{file}: acceptance entry matches no live commitment entry") + else: + matched.add(key(entry)) + agreed.update(entry["line_item_ids"]) + elif key(entry) not in commitments: + checker.errors.append(f"{file}: {role} entry matches no commitment entry before it") + elif key(entry) not in matched: + live.discard(key(entry)) + for name, item in items.items(): + if (item.get("status") == "agreed") != (name in agreed): + checker.errors.append(f"{file}: {name} has status {item.get('status')!r}, " + f"and assent[] {'has' if name in agreed else 'has no'} matching pair") + + +def check_change_requests(checker, file, doc): + """Section 4.4: each entry names an agreed line item at its agreed version.""" + items = {i["line_item_id"]: i for i in doc.get("line_items", [])} + for n, entry in enumerate(doc.get("change_requests", [])): + where = f"{file}: change_requests[{n}].baseline" + baseline = entry.get("baseline", {}) + named = baseline.get("line_item_id") + if baseline.get("proposal_id") != doc.get("proposal_id"): + checker.errors.append(f"{where}: names another proposal") + if named is None: + checker.errors.append(f"{where}: line_item_id is REQUIRED here") + elif items.get(named, {}).get("status") != "agreed": + checker.errors.append(f"{where}: {named} is not an agreed line item") + versions = {e["version"] for e in doc.get("assent", []) + if e["role"] == "acceptance" and named in e["line_item_ids"]} + if baseline.get("version") not in versions: + checker.errors.append(f"{where}: no acceptance entry names {named} at that version") + notice = entry.get("cancellation") or {} + option = notice.get("option_id") + if option is not None: + policy = items.get(named, {}).get("cancellation_policy") or {} + if option not in policy.get("selected", policy.get("included", [])): + checker.errors.append(f"{file}: change_requests[{n}].cancellation: " + f"{option} is not an option in effect") + terms = next((o for o in policy.get("available", []) if o.get("id") == option), {}) + if notice.get("cancelled_share", 0) > applicable_max_share(terms, notice): + checker.errors.append(f"{file}: change_requests[{n}].cancellation: " + f"cancelled_share is above the max_share that applies") + + +# 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() + + check_digest_ignores_empty_buyer_supplied(checker, load_yaml(EXAMPLES / "proposal-agreed.yaml"), spec.removal) + + digests = {} + for name in ("proposal-agreed.yaml", "proposal-issued.yaml"): + doc = load_yaml(EXAMPLES / name) + check_assent(checker, name, doc) + check_change_requests(checker, name, doc) + for entry in doc.get("assent", []): + digest = record_digest(doc, spec.removal, entry["line_item_ids"]) + digests[name] = digest + if entry["record_digest"] != digest: + checker.errors.append(f"{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/scripts/check_pricing_fixtures.py b/scripts/check_pricing_fixtures.py new file mode 100644 index 0000000..458731c --- /dev/null +++ b/scripts/check_pricing_fixtures.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Pricing arithmetic vectors for #30, #94 and #115. + +These test this adapter's arithmetic against fixed expected values. They read neither +the specification nor the examples; scripts/check_examples.py does that. +""" + +import argparse +import json +import math +import re +from fractions import Fraction +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DECIMAL = re.compile(r"-?\d{1,18}(?:\.\d{1,9})?\Z") + + +class ProfileError(ValueError): + pass + + +def require(condition, code): + if not condition: + raise ProfileError(code) + + +def number(value): + # Decimal strings make these test vectors independent of binary floats. + require(isinstance(value, str) and DECIMAL.fullmatch(value), "invalid_number") + return Fraction(value) + + +def shape(obj, required, optional=()): + require(isinstance(obj, dict), "invalid_shape") + require(set(required) <= obj.keys(), "missing_field") + require(obj.keys() <= set(required) | set(optional), "unsupported_field") + + +def check_range(bounds, actual, outside): + """Apply a Range (section 2.2) of decimal strings to one value.""" + shape(bounds, (), ("min", "max")) + require(bounds, "empty_bounds") + values = {key: number(value) for key, value in bounds.items()} + require(all(value >= 0 for value in values.values()), "negative_bound") + require(len(values) < 2 or values["min"] <= values["max"], "inverted_bounds") + require(actual >= values.get("min", actual) and actual <= values.get("max", actual), outside) + + +def resolve(case): + """Resolve already selected adjustments using exact rational arithmetic. + + Keys take their names from sections 3.1, 5.6 and 5.6.1. basis, amount and + pricing_id are the commitment's, and adjustments holds the deltas in effect. + """ + shape(case, ("pricing", "basis", "amount", "adjustments"), ("pricing_id", "commitment_bounds")) + rows = case["pricing"] + require(isinstance(rows, list) and rows, "missing_pricing") + for row in rows: + shape(row, ("pricing_id", "cost_method", "list_rate", "currency"), ("min_rate", "unit_bounds")) + require(isinstance(row["pricing_id"], str) and row["pricing_id"], "invalid_pricing_id") + ids = [row["pricing_id"] for row in rows] + require(len(ids) == len(set(ids)), "duplicate_pricing_id") + require(len(rows) == 1 or "pricing_id" in case, "pricing_choice_required") + chosen = case.get("pricing_id", ids[0]) + require(chosen in ids, "unknown_pricing_id") + row = rows[ids.index(chosen)] + method = row["cost_method"] + require(isinstance(method, str), "invalid_shape") + require(method in ("CPM", "CPMV", "CPC"), "unsupported_cost_method") + require(isinstance(row["currency"], str) and re.fullmatch(r"[A-Z]{3}", row["currency"]), + "invalid_currency") + require(case["basis"] in ("spend", "units"), "unsupported_basis") + amount = number(case["amount"]) + require(amount > 0, "nonpositive_amount") + rate = number(row["list_rate"]) + require(rate >= 0, "nonpositive_rate") + cpm_delta, flat, factors = Fraction(0), Fraction(0), [] + adjustments = case["adjustments"] + require(isinstance(adjustments, list), "invalid_shape") + adjustment_ids = set() + for adjustment in adjustments: + shape(adjustment, ("id",), ("delta_cpm", "delta_fraction", "delta_flat")) + key = adjustment["id"] + require(isinstance(key, str) and key, "invalid_adjustment_id") + require(key not in adjustment_ids, "duplicate_adjustment") + adjustment_ids.add(key) + require(len(adjustment) > 1, "empty_adjustment") + if "delta_cpm" in adjustment: + # Section 3.1: it enters only a CPM or CPMV rate, and an option priced + # only by it doesn't resolve against any other entry. + delta = number(adjustment["delta_cpm"]) + if method in ("CPM", "CPMV"): + cpm_delta += delta + else: + require(len(adjustment) > 2, "incompatible_delta_cpm") + if "delta_flat" in adjustment: + fee = number(adjustment["delta_flat"]) + require(fee >= 0, "unsupported_flat_credit") + flat += fee + if "delta_fraction" in adjustment: + factor = 1 + number(adjustment["delta_fraction"]) + require(factor > 0, "invalid_delta_fraction") + factors.append(factor) + rate += cpm_delta + for factor in factors: + rate *= factor + # Section 5.6: exact decimal, rounded once, half up, to 6 places. + rate = Fraction(math.floor(rate * 10**6 + Fraction(1, 2)), 10**6) + require(rate > 0 if case["basis"] == "spend" else rate >= 0, "nonpositive_rate") + # min_rate is a hint (section 5.6), so it never changes the result. + require(number(row.get("min_rate", "0")) >= 0, "negative_min_rate") + rate_per = 1000 if method in ("CPM", "CPMV") else 1 + if case["basis"] == "spend": + total = amount + require(total >= flat, "fees_exceed_spend") + media = total - flat + # Section 5.6.5: rounded down to a whole unit. + units = Fraction(math.floor(media / rate * rate_per)) + else: + units = amount + media = units / rate_per * rate + total = media + flat + commitment_bounds = case.get("commitment_bounds", {}) + shape(commitment_bounds, (), ("spend",)) + if "spend" in commitment_bounds: + check_range(commitment_bounds["spend"], total, "outside_spend_bounds") + if "unit_bounds" in row: + check_range(row["unit_bounds"], units, "outside_unit_bounds") + return {"pricing_id": chosen, "currency": row["currency"], "min_rate_check": "not_checked", + **{key: str(value) for key, value in { + "agreed_rate": rate, "flat_charges": flat, "media_spend": media, + "seller_total": total, "derived_units": units}.items()}} + + +def evaluate(case): + try: + return {"result": resolve(case)} + except ProfileError as error: + return {"error": str(error)} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true", help="emit per-vector evidence") + args = parser.parse_args() + corpus = json.loads((ROOT / "fixtures/pricing/cases.json").read_text()) + results = [] + for vector in corpus["cases"]: + actual = evaluate(vector["input"]) + results.append({"id": vector["id"], "passed": actual == vector["expected"], + "actual": actual, "expected": vector["expected"]}) + passed = sum(result["passed"] for result in results) + if args.json: + print(json.dumps({"profile": corpus["profile"], "passed": passed, + "total": len(results), "cases": results}, indent=2)) + else: + for result in results: + print(("PASS " if result["passed"] else "FAIL ") + result["id"]) + if not result["passed"]: + print(json.dumps(result, indent=2)) + print(f"{passed}/{len(results)} pricing arithmetic vectors pass; " + "this reads neither the specification nor the examples") + return int(passed != len(results)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 1dfacef..f3c95f4 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -5,6 +5,368 @@ issue that caused it, so the comment record and the spec history line up. ## [Unreleased] +- § 1.1, § 2.1, § 2.6, § 3.3, § 4.1.1, § 5.2, § 5.2.2, § 5.2.3, § 5.2.9, § 5.4, § 5.6.4, § 5.7, + § 5.7.1, § 5.9, § 6.3, § 6.4, § 6.7, § 6.10, § 6.11, § 7, appendices, examples, checker — video + and audio inventory. `break_position` sells position and pod slot as priced + `BreakPositionOption`s (§ 5.2.3). `programs` makes content selectable: a `ProgramOption` nests + AdCOM `Content` under `content`, beside `id` and a named `schedule` (`EventSchedule`), and + § 5.2.9 says what happens when an event doesn't air. `content_detail` is removed, and + `episodes` is its own field. `placement` has a `Placement` key table, takes `IntegerFlag` on + `reward`, `instl`, and `skip`, and gains `feed` and `ssai`. It is REQUIRED when `channel` is + `video` or `audio`, `plcmt` when `channel` is `video`, and the presence of `plcmt` states that + a line item is video. `serving_mode` drops `client_side` and `ssai` and defines `dai`, + `scheduled`, and `embedded` for every channel. An `embedded` line item carries `retention` and + `controlling_measurement`, its post-flight delivery counts until `final_after` elapses, and + its `time_zone` isn't `viewer_local`. `plcmt` and `feed` link their AdCOM lists and take any + code in them. `source_type` links its list and takes any code but Unknown (`0`). + `creative_delivery.protocol` and `third_party_tags` become `protocols[]` of AdCOM Creative + Subtypes codes and `other_protocols[]` of terms. `production.available[].cost` is removed: + `deltas` prices a build. `build_owner` becomes `built_by`, and `production` says what nothing + in effect means. Breaking + ([#99](https://github.com/IABTechLab/OpenProposal/issues/99), + [#100](https://github.com/IABTechLab/OpenProposal/issues/100), + [#101](https://github.com/IABTechLab/OpenProposal/issues/101), + [#89](https://github.com/IABTechLab/OpenProposal/issues/89)). + +- § 2.1, § 2.2, § 2.6, § 3.3, § 5.2, § 5.5, § 5.6, § 5.6.4, § 5.7.1, § 5.9, § 6.9, appendices, + examples, checker — DOOH properties, plays, and impressions. When `channel` is `dooh`, a + property is a set of screens of one venue type. It carries `venue_type`, `allocation`, + `min_screens`, `screens[]`, `slots_per_loop`, and `sync_groups[]`, and a summary lists + `venue_type`. `viewer_local` is each screen's own zone. `frequency_cap.per` is a + `VocabularyTerm` with the core term `screen`, and `availability.unit` gains + `slots_per_loop`. § 5.6.4 defines a play and an impression. `delivery_counting`, REQUIRED + on a `dooh` line item, holds `play_tolerance_ms`, `proof_of_performance`, + `partial_play_credit`, and a nested `impression_multiplier`. A line item that counts only + plays needs the outer object alone. `proof_of_performance` has a key table, and + `evidence[]` is a `VocabularyTerm`. The committed quantity on a `dooh` line item has one + rule, in § 5.6.4: it is in the committed entry's `unit`. The multiplier's source controls + the count of impressions, and `metric` gains the count term `impressions`. A `loop_time` + share derives from the loop, a domination is `guaranteed` with sole exclusivity, and a + share's eligible screens are the line item's screen set. Breaking + ([#92](https://github.com/IABTechLab/OpenProposal/issues/92), + [#96](https://github.com/IABTechLab/OpenProposal/issues/96), + [#79](https://github.com/IABTechLab/OpenProposal/issues/79), + [#134](https://github.com/IABTechLab/OpenProposal/issues/134)). + +- § 3.1, § 5.5, § 5.6.5, § 5.7, § 5.7.1, § 5.9, § 5.10, § 8, appendices, examples, docs, + checkers — who measures, the metric terms, and the terms settlement reads. Settlement reads + the terms in force: the agreed record plus any change the applying system has applied. + § 5.7.1 says what OpenDirect 2.1 `Stats` reports. It also says that no standard in § 1.1 + states a final count, a delivered share of impressions, a reconciliation, or a remedy. + `controlling_measurement` names whose count is invoiced and when it is final. It is + REQUIRED with `committed_metrics[]`, `commitment_bounds.spend`, or a `pricing[].unit_bounds`, + and `discrepancy_threshold` names the other measurement. `measurement_source` option + `vendor` becomes `measured_by`, and `counted_by` names who counts. Both are a `Measurer`, + compared byte for byte, and a `qualifier` beside either names a product or method. + `verification[]` holds the domains of third parties that can attest enforcement. + `CommittedMetric` has a key table: `value` becomes `level`, and `direction` is REQUIRED. + `basis` and `seller_reported` are removed: the term names the standard, and + `measurement_source` says who measures. `metric` has a core table of `viewable_rate`, + `completion_rate`, and `ivt_rate`, each naming its standard, and `viewability_standard` + gains the core term `mrc`. § 3.1 lets a named type hold the keys of a `Selectable[T]`, so + `shortfall_remedy` is a `ShortfallRemedy`, and `RemedyTerms` has a key table. § 5.7.1 + states what a shortfall is and how `shortfall_remedy` settles it. `guaranteed` covers a + `FLAT` entry, which has no units. `feed_type` becomes `report_type`, a `VocabularyTerm`, + and `delivery_mechanism` becomes `delivery_method`. `provided_metrics[]` takes the § 5.6.5 + `unit` terms and the § 5.7.1 rate terms. + `billing.invoice_period` points at § 5.7.1, and § 8 gains question 10, on a shortfall with + no remedy left. Breaking + ([#93](https://github.com/IABTechLab/OpenProposal/issues/93), + [#95](https://github.com/IABTechLab/OpenProposal/issues/95), + [#134](https://github.com/IABTechLab/OpenProposal/issues/134), + [#38](https://github.com/IABTechLab/OpenProposal/issues/38)). + +- § 1.1, § 2.1, § 2.2, § 2.4, § 2.5, § 3.1 to § 3.3, § 4.1, § 4.3.2, § 4.4, § 5.6, § 5.6.1, § 5.6.3, + § 5.6.5 to § 5.6.7, § 6, § 8, appendices, examples, docs, checkers, pricing fixtures — pricing. + `cost_method` is an open vocabulary, with core terms that have a `unit` and a rate per in + § 5.6.5. An extension term is domain-prefixed and has `rate_per`, `unit_label`, and a + `cost_definition` that is a `DocumentRef`. `pricing[]` entries are alternatives, each with a `pricing_id`, and a commitment + names one. `Amount` is the one money type, read in the committed entry's `CurrencyCode`, so + `commitment.currency` and the currency on `requires.min_commitments[]` are gone. Every `Amount` is + on one basis, which § 8 asks about. `min_spend` and the budget keys become + `commitment_bounds.spend`, and `commitment_bounds` gains the informative `earliest_start` and + `latest_end`. `min_impressions` and `max_impressions` become `pricing[].unit_bounds`, which counts + the entry's unit and which a `grant` MAY widen. `derived_units` rounds down, `FLAT` and `TIME` set + the figures from the rate and the flight, and `FLAT` delivery is days of the flight. + `Availability` has a key table: `type` becomes `certainty`, `as_of` becomes `computed_at`, `basis` + goes, and `window` names the dates. A cancellation option has `notice_duration`, `fees[]`, and + `dated_shares[]`, and a `change_requests[]` entry's `cancellation` is a `CancellationNotice`. An + exclusivity option is an `ExclusivityOption`, with a `window`. The pricing vectors use these + names and round as § 5.6 and § 5.6.5 state. A granted rate is the `list_rate` of the next + `version`, so `accepted_counter` and `selection_key` are removed. Breaking `agreed_rate` is never negative, and is above 0 under `spend` on an entry other than `FLAT` or `TIME`. A `counter` of `list_rate` may also change `included[]`. `separation` adds `location`, and a `demographic` term states how it treats co-viewers and unknown age. + ([#94](https://github.com/IABTechLab/OpenProposal/issues/94), + [#98](https://github.com/IABTechLab/OpenProposal/issues/98), + [#117](https://github.com/IABTechLab/OpenProposal/pull/117), + [#127](https://github.com/IABTechLab/OpenProposal/issues/127), + [#115](https://github.com/IABTechLab/OpenProposal/issues/115), + [#70](https://github.com/IABTechLab/OpenProposal/pull/70), + [#30](https://github.com/IABTechLab/OpenProposal/issues/30), + [#125](https://github.com/IABTechLab/OpenProposal/issues/125), + [#121](https://github.com/IABTechLab/OpenProposal/issues/121)). + +- § 2.4, § 3.4, § 4.1.1, § 4.1.3, § 4.3, § 4.3.1, § 4.3.2, § 4.3.4, § 5.6, § 5.6.1, § 5.6.2, + appendices, examples, checkers — holds. `hold_status` splits in two: `hold_terms`, + `{ available, duration }`, is `seller-set` and inside the record, and `hold` is `lifecycle` + and outside it, so the removal path has no exception. An absent `hold_terms` means + `available` is `false`, and asking for a hold it doesn't offer returns `not_offered`. + `state` and `hold_expires_at` become `status` and `valid_until`, and `none` goes: `hold` + is absent until the buyer first asks. `HoldStatus` adds `declined`, a lapsed hold can be + asked again, and the transitions are exhaustive. The held terms are the offered terms of + `hold.version`. `hold` gains `version`, `position`, the queue order of granted holds, and + two optional keys: `requested_terms`, the `LineItemWrites` the buyer asks to hold, and + `held_availability`, what the seller reserves. `held_availability` is typed + `Availability`, whose keys the `availability` row lists until its key table lands. + `hold_scope` is removed: under `linked_execution: all`, holds move as a unit. The re-offer + is a listed transition, and `valid_until` states its cap. The CTV example no longer offers + a hold on a `private_auction` line item. Breaking `held` means reserved inventory, and a hold stays `held` while a `commit` made during it is open. `hold.reference` holds the seller's id for the reservation, and `renewal_right` offers a right to the next cycle. + ([#66](https://github.com/IABTechLab/OpenProposal/issues/66), + [#134](https://github.com/IABTechLab/OpenProposal/issues/134)). +- § 1.1, § 2.1, § 2.6, § 5.4, § 5.5, § 5.6, § 5.9, § 5.10, § 6.2, § 8, appendices, examples, + docs, checkers — downstream handoff. § 5.9 has one table of `transaction_mechanism` values. + It states which are deal mechanisms and which `delivery_type` each allows. § 1.1 keys on + those values, with each one's `reference_type` and change path. An option is + `{ id, delivery_type }`. `delivery_type` says what the seller promises: `guaranteed`, + `share_of_voice`, or `non_guaranteed`. A share of voice carries `sov_basis` and `sov_share`, + and `availability` is a forecast under `non_guaranteed`. `open_auction` is removed: with no + deal ID, no bid request can name a line item. A line item agreed under a deal mechanism maps + to a Deals API 1.0 `Deal`, which the system named by `deal_origin` pushes. The seller records + the deal ID as an `external_references[]` entry with `reference_type: deal`. The Deals API + table maps a `CPM` entry and says other cost methods have no mapping yet. `agreed_rate` is the + deal's price, and `pricing[].price_type` says how it clears. § 1.1 gains an OpenDirect 2.1 + mapping for a `direct_io` line item and a `programmatic_guaranteed` reservation. It covers who + acts, which `cost_method` values have a `ratetype`, and what the record doesn't hold. Each + `creative_delivery.method` is defined, and it routes the creative under any mechanism: + `assets[]` and `materials_due` read only where creative reaches the seller. `buying_route` + and `supported_order_systems[]` are removed, and `serving_platform` is a `Domain`. The bonus + example sells as `direct_io`. Breaking A `VersionRef` outside its proposal carries `seller_domain`, and a Deals API push may carry the agreed baseline in `ext.baseline`. + ([#132](https://github.com/IABTechLab/OpenProposal/issues/132), + [#133](https://github.com/IABTechLab/OpenProposal/issues/133), + [#93](https://github.com/IABTechLab/OpenProposal/issues/93), + [#109](https://github.com/IABTechLab/OpenProposal/issues/109)). + +- § 1.2, § 2.1, § 2.4, § 2.5, § 2.7, § 4.1.1, § 4.1.2, § 4.1.3, § 4.3, § 4.4, § 5.6.1, § 5.10, + § 6.8, § 8, appendices, examples, docs, issue templates, checkers — after `agreed`. § 1.2 separates what a + request or response carries, which this document states, from the envelope and binding of the + message. "Message binding" names that layer. The contract has a provisional name, the hub is + named, and the contract governs only once it is published on a standards track. The baseline + is the `VersionRef` that names the agreed line item, at the `version` of its agreed record. + § 4.4 adds `change_requests[]`, outside the record, with the OpenDirect 2.1 ChangeRequest + fields except `webhook`, plus `baseline`, `actor`, `at`, and `cancellation`. `cancellation` is typed `CancellationNotice`, whose key table lands with the pricing change. `orderid` is + REQUIRED only when the line item has an `order` reference. `accountid` and `requesterid` are + OPTIONAL, because only an OpenDirect 2.1 ChangeRequest requires them. `status` is an + `ApprovalStatus`, a change of `status` is a new entry, and an entry MAY carry `ext`. § 8 asks + how an entry states a change other than a cancellation. `correlation_id` is removed: the + baseline and `external_references[]` do the job. `ExternalReference` has a key table, `role` + becomes `reference_type`, and `io` gives way to `order` and `line`. A receiver that doesn't know + a `reference_type` ignores the entry. § 5.10 and § 6.8 take new titles. Breaking + ([#59](https://github.com/IABTechLab/OpenProposal/issues/59), + [#68](https://github.com/IABTechLab/OpenProposal/issues/68)). + +- § 1.2, § 2.2, § 2.4, § 2.8, § 3.4, § 4, § 4.1, § 4.1.1, § 4.1.2, § 4.1.3, § 4.2, § 4.3, + § 4.3.1, § 4.3.2, § 4.3.4, § 5.5, § 5.6, § 5.6.1, § 5.8, § 5.9, § 5.10, § 6, § 7, appendices, + examples, checkers — what is signed, and how a seller checks it. `record_digest` sits only on + `assent[]` entries, and the document-level field and its "two values" are gone. § 4.1.1 states + the exact bytes of the digest input. A removal leaves an empty object in place, except + `buyer_supplied`, and nothing fills in a default. `agreed_rate` takes the § 5.6 result, a + `DocumentRef` enters whole, and the YAML conversion and validity tests are stated. The removal + list drops `seller_rate`, `deal_id_issuance`, `commitment.version`, and + `commitment.idempotency_key`. It adds `min_rate`, `best_for[]`, `not_suitable_for[]`, + `seasonality_notes`, `segments[]`, and `suppression_list`. `AssentEntry` has a key table: `role` + is `commitment`, `acceptance`, `withdrawal`, or `decline`, and `signed_at` becomes `at`. + `signer` is removed, and `idempotency_key` moves here from `commitment`. Only `assent[]` decides + whether a line item is agreed, and a history entry voids nothing. A `commitment` entry stops + being live when its `commit` goes stale or void, and an `acceptance` matches only a live entry. + A `Commit` holds the buyer's entry and its `writes`. A `CommitResult` holds the entries and any + `availability` figures a `decline` states. The seller's check on `version` becomes a recomputed + digest. Its rejections come in order: `expired`, `stale_version`, `not_committable`, then + `record_mismatch`. A `commit` writes an entry only on an issued proposal, and a replay returns + the result as it stands. An entry travels in the body of an HTTP message signed under the + OpenProposal profile of Web Bot Auth (§ 2.8). `Content-Digest` covers the body, and requests + and responses are both signed. Which signing origins a party accepts is its own configuration, + and any signed copy of an entry is evidence of it. The evidence is OPTIONAL, and `agreed` + doesn't depend on it. Each party keeps the agreed record. `terms_ref` and + `terms_acceptance_required` become `terms_refs[]` of `DocumentRef`, and a `commitment` entry + accepts them all. An absent list accepts no document. A `DocumentRef`'s `url` is an `HttpsUrl`, + a new type, and `list_ref` is a `DocumentRef`. `commitment.proposal_id` and + `commitment.version` go, because the entry pins the version. `seller_rate` is removed. Breaking A `commitment` names at least one advertiser, a `decline` states its `ground`, and a seller answers a `commit` under a firm entry while the entry holds. + ([#63](https://github.com/IABTechLab/OpenProposal/issues/63), + [#27](https://github.com/IABTechLab/OpenProposal/issues/27), + [#24](https://github.com/IABTechLab/OpenProposal/issues/24), + [#34](https://github.com/IABTechLab/OpenProposal/issues/34), + [#45](https://github.com/IABTechLab/OpenProposal/issues/45), + [#73](https://github.com/IABTechLab/OpenProposal/issues/73), + [#75](https://github.com/IABTechLab/OpenProposal/issues/75)). +- § 1.1, § 2.1, § 2.2, § 2.6, § 3, § 3.1 to § 3.4, § 4.1, § 4.1.1, § 4.1.3, § 4.3.1, + § 4.3.2, § 5.1.1, § 5.2, § 5.2.1, § 5.4, § 5.5, § 5.6, § 5.6.1, § 5.6.3, § 5.7, § 5.9, + § 6, appendices, examples, checker — what the buyer writes, and how the rate is computed. + `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.1, with an informative CTV example note + ([#42](https://github.com/IABTechLab/OpenProposal/issues/42)). +- § 2, § 4.1, § 5.6 — `linked_execution` on the proposal and `requires` on + line items, for linked execution and bonus line items + ([#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)). +- § 2.1, § 5.2 — `placement` on the line item states what every impression + is, in AdCOM 1.0 and OpenRTB 2.6 terms: rewarded, interstitial, video + placement subtype, skippability, playback, API frameworks and refresh. + `plcmt` is REQUIRED for video ads. Breaking + ([#89](https://github.com/IABTechLab/OpenProposal/issues/89)). + ## [3.0-draft-1] — 2026-09-22 Initial draft published for public comment. Ported from the working-group diff --git a/spec/openproposal-3.0.md b/spec/openproposal-3.0.md index 6940958..ddfb7ea 100644 --- a/spec/openproposal-3.0.md +++ b/spec/openproposal-3.0.md @@ -36,19 +36,158 @@ 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 OpenProposal does not replace IAB Tech Lab execution standards. Each line item -lands in the one its `transaction_mechanism` maps to: +lands in the one that the `transaction_mechanism` in effect (§ 5.9) maps to: -| OpenProposal concept | Downstream standard | +| `transaction_mechanism` | Downstream standard | `reference_type` (§ 5.10) | Change path | +| :--- | :--- | :--- | :--- | +| `direct_io` | OpenDirect 2.1 · Order + Line | `order`, `line` | An [OpenDirect 2.1 ChangeRequest](https://github.com/InteractiveAdvertisingBureau/OpenDirect/blob/82bc294314aba017fc639ba634aecc53adfd675f/OpenDirect.v2.1.final.md#object_changerequest) on the booked Line (`lineid`). `change_requests[]` (§ 4.4) records it against the baseline `VersionRef` (§ 2.2). A partial cancel uses ChangeRequestLine, and a full cancel uses Line `?cancel` at the effective date | +| `programmatic_guaranteed` | OpenDirect 2.1 · Order + Line, as the reservation; Deals API 1.0 · `Deal` | `order`, `line`, `deal` | The reservation: as for `direct_io`. The deal: as for `preferred_deal` | +| `preferred_deal` | Deals API 1.0 · `Deal` + OpenRTB 2.6 · `Deal` | `deal` | By agreement between implementers, against the same baseline. A full stop is `sellerstatus` 4 (archived) | +| `private_auction` | Deals API 1.0 · `Deal` + OpenRTB 2.6 · `Deal` | `deal` | As for `preferred_deal` | + +Buying in the OpenRTB 2.6 open auction is outside this specification: with no deal +ID, no bid request can name a line item. + +The deal rows name no operation: the Deals API, published as the +[Deal Sync API 1.0](https://github.com/IABTechLab/deal-api/blob/3028ddcb8d5faea44bdab272aeaa9be6b5980f81/deal1.0.md#matching-bid-requests-to-deals), "does not support deal revisions". + +**Deals API handoff.** A line item agreed under a deal mechanism (§ 5.9) becomes a +Deals API 1.0 `Deal`. The system named by `deal_origin` (§ 5.9) pushes the `Deal` to +the DSP named by `buyer_requirements.seats.dsp`. The deal ID is in that system's namespace: it +assigns the ID, or accepts one the seller supplies. The `Deal` takes these +values from the agreed line item: + +| Deals API 1.0 | OpenRTB 2.6 | From the agreed line item | +| :--- | :--- | :--- | +| `id` | `Deal.id`, `Bid.dealid` | None: the ID in `deal_origin`'s namespace. The `external_references[]` entry with `reference_type: deal` records it (§ 5.10) | +| `origin` | | `deal_origin` | +| `seller` | | The proposal's `seller.domain` | +| `wseat` | `Deal.wseat` | `buyer_requirements.seats.ids[]` | +| `adtypes` | | From `channel`: `display` 1, `video` 2, `audio` 3, `native` 4, and `ctv` 2 when `placement.plcmt` is present. Otherwise unset: the record doesn't state the ad's type | +| `terms.startdate`, `terms.enddate` | | `commitment.flight.start`, `commitment.flight.end` | +| `terms.countries` | | The countries of `geo` or `markets` in effect, in alpha-3 (below), without a country excluded whole. Unset when the line item declares neither | +| `terms.dealfloor` | `Deal.bidfloor` | `agreed_rate` of the committed `pricing[]` entry | +| `terms.cur` | `Deal.bidfloorcur` | That entry's `currency`, always sent | +| `terms.pricetype` | `Deal.at`, the same value, omitted for Dynamic (0) | That entry's `price_type`, always sent | +| `terms.guar` | `Deal.guar` | `1` when the mechanism in effect is `programmatic_guaranteed`, `0` otherwise | +| `terms.units` | | `commitment.amount` when `basis` is `units`, otherwise `derived_units` (§ 5.6.5) | +| `terms.totalcost` | | The seller total (§ 5.6.5) | +| `ext.baseline` | | The agreed baseline `VersionRef` (§ 2.2), with `seller_domain`, so a DSP can join the deal to its record. OPTIONAL. The Deals API 1.0 doesn't define this key | + +`commitment.flight.end` and `terms.enddate` are the flight's last second, +inclusive. The Deals API 1.0 doesn't say whether `enddate` is inclusive, so this mapping +states it. + +`terms.dealfloor` and `Deal.bidfloor` are prices per thousand impressions, so this +table maps a `CPM` entry. Other cost methods have no mapping yet. + +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. + +`terms.countries` takes the same alpha-3 code. The Deals API 1.0 says "ISO-3166-3", and +this mapping reads that as ISO 3166-1 alpha-3. The list names whole countries, so `US` +minus `US-TX` sends `USA`, and `geo` in the agreed record holds the exclusion. The record +holds no country for an extension term, so the system that pushes the `Deal` supplies the +one the term lies in. + +The line item's `status` ends at `agreed` (§ 5.1). After the push, the deal's +state is the Deals API `sellerstatus` and, for each seat, `BuyerStatus.status`. +OpenProposal has no field for either. + +**OpenDirect handoff.** A `direct_io` line item that books in +[OpenDirect 2.1](https://github.com/InteractiveAdvertisingBureau/OpenDirect/blob/82bc294314aba017fc639ba634aecc53adfd675f/OpenDirect.v2.1.final.md#object_line), +and the reservation of a `programmatic_guaranteed` line item, become a `Line` in an +`Order`. OpenDirect says who acts: on the seller's OpenDirect server, the buyer adds +the `Order` to its `Account`, adds the `Line`, and reserves or books it. The `Order` +and `Line` take these values from the agreed line item: + +| OpenDirect 2.1 | From the agreed line item | | :--- | :--- | -| Direct buy | OpenDirect 2.1 · Order + Line | -| Programmatic guaranteed | OpenDirect 2.1 + Deals API · `deal_id` | -| PMP deal | Deals API + OpenRTB 2.6 · Deal | -| Open market | OpenRTB 2.6 · open auction, no deal | +| `Order.currency` | The committed `pricing[]` entry's `currency` | +| `Line.startdate`, `Line.enddate` | `commitment.flight.start`, `commitment.flight.end` | +| `Line.ratetype` | That entry's `cost_method`: `CPM`, `CPMV`, and `CPC` as written, `FLAT` as `FlatRate`, and `TIME` with `rate_unit: day` as `CPD` | +| `Line.rate` | That entry's `agreed_rate` | +| `Line.qty` | `commitment.amount` when `basis` is `units`, `1` for `FLAT`, otherwise `derived_units` (§ 5.6.5) | +| `Line.cost` | The seller total (§ 5.6.5) | +| `Line.frequencycount`, `Line.frequencyinterval` | `frequency_cap.count` in effect, and `frequency_cap.period` in OpenDirect's spelling, with `flight` as `LineDuration` | + +Any other `cost_method`, and `TIME` with another `rate_unit`, has no `ratetype` and no +mapping here. A `direct_io` line item under one books in another order system, which +its `order` entry names (§ 5.10), and changes there. OpenDirect's cap counts a unique user, +so it has no key for `frequency_cap.per`. + +The record holds no value for `Order.name` and `Line.name`, or for `Order.accountid`, +`Order.publisherid`, `Order.advertiserbrandid`, and `Line.productid`, which name the +server's own `Account`, `Organization`, `AdvertiserBrand`, and `Product` resources. +OpenDirect 2.1 has the buyer obtain those ids from the publisher and its server, and no +key here says which `Product` a line item books. The ids of the `Order` and the `Line` +go in `external_references[]`, and the `Line`'s state is its `bookingstatus`. OpenProposal has no field for it. + +### 1.2 Relationship to other AAMP artifacts + +OpenProposal is normative for the proposal and line-item object: its fields, +their mutability, the declared option space, and the binding record. It is also +normative for the lifecycle of the object to its terminal statuses, and for what +a section of this document says a request or response carries, such as `errors[]` +(§ 2.4), an `AvailabilityRead` (§ 3.3), and a `Commit` and its `CommitResult` (§ 5.6.1). + +OpenProposal is not normative for the envelope or binding of a message, or the +mapping of its values to the types of the AAMP wire and lifecycle contract +(provisional name), including monetary values. It is not normative for replay +semantics beyond the rule in § 5.6.1, for any lifecycle after `agreed`, or for how a +message states the buyer's `Party`, the form of a brief, or how an issued proposal +reaches the buyer. These belong to that contract. + +The correspondence between values defined in this document and the values of that +contract (status, negotiation actions, transaction mechanism, money, hold status) is for the +[AAMP hub](https://github.com/IABTechLab/AAMP/blob/0f3eaff7a979df835f6bee5290333c8e3666c95d/README.md) +to maintain. This document references that correspondence and does not define it. +Until that contract is published on an IAB Tech Lab standards track, the +correspondence is informative and this document governs. Once it is published, +where the two documents disagree on a concern this section assigns to the contract, +the contract governs. Where they disagree on a concern this section assigns to +OpenProposal, this document governs. + +**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 | +| :--- | :--- | :--- | :--- | :--- | +| Message authentication | Which origin sent this request or response? | An [HTTP Message Signature](https://www.rfc-editor.org/rfc/rfc9421), with the key in that origin's [Web Bot Auth](https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/) directory | This specification's Web Bot Auth profile (§ 2.8) | Nothing. The object never carries or re-proves it | +| Authorization | What may this party, or its agent, do? | The seller's eligibility rules; a mandate the principal publishes or signs | This specification for the rules (§ 5.8); the AAMP authority document (provisional name) for mandates | The rules only, never the proof | +| Legal assent | Which legal entity agreed to which bytes? | Two matching `assent[]` entries over one `record_digest`, with both parties named in the record; as optional evidence, the signed messages that carried them | This specification for the record, digest, entry, and what each party keeps (§ 4.1.1, § 4.1.2); § 2.8 for how a message is signed and its key found | Parties, digest, entries | + +A message signature proves an `https` origin. It does not prove which party that +origin speaks for: a hosted agent's origin is the host's, and a mandate the party +publishes or signs connects them. An `assent[]` entry travels in the body of a +signed message, as optional evidence, and § 4.1.2 states when the signature +attributes it to a party. Reading a public file (§ 2.3) needs none of this. + +**After `agreed`.** This specification's lifecycle ends at `agreed`. The +downstream standard executes against the agreed line item as baseline: the +`VersionRef` (§ 2.2) that names the line item and the `version` of its agreed +record (§ 4.1.2). `external_references[]` (§ 5.10) holds the ids that join it to +downstream systems. The parties request and apply any change to its +terms downstream, against the options selected in that record, +`cancellation_policy` and `shortfall_remedy` included. A change is not a state or +action of this object, and it changes no agreed term; `change_requests[]` (§ 4.4) +records the request beside the agreed terms. The applying system answers which +terms are in force. + +NOTE: The IAB Tech Lab +[`iab-agentic-primitives`](https://github.com/IABTechLab/iab-agentic-primitives/blob/ee9ea3c1c612b1be6ae00b75e0ab1b81435f62cc/README.md) +repository defines a post-booking change request and its lifecycle +(`ChangeRequest`, `ChangeRequestLifecycle`). `change_requests[]` (§ 4.4) takes the +OpenDirect 2.1 fields, not that model. A change request made under it against an +agreed line item is expected to name the baseline by the identity in this section. +Defining that reference is a matter for that repository. --- @@ -56,59 +195,451 @@ lands in the one its `transaction_mechanism` maps to: ``` 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 over the message binding +(§ 1.2) 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[] +episodes +programs.available[].id +programs.available[].content.series +programs.available[].content.title +placement.reward +placement.instl +placement.plcmt +placement.skip +placement.feed +break_position.available[].id +break_position.available[].delay +break_position.available[].slotinpod +properties.available[].id +properties.available[].name +properties.available[].domains[] +properties.available[].venue_type +audiences.available[].id +audiences.available[].name +formats.included[] +geo +markets +pricing[].pricing_id +pricing[].cost_method +pricing[].list_rate +pricing[].currency +pricing[].price_type +pricing[].unit_bounds +commitment_bounds +addressable_scale +committed_metrics[] +delivery_counting.impression_multiplier.impression_type +delivery_counting.impression_multiplier.source_type +required_credentials[] +buyer_requirements.allowed_dsps[] +transaction_mechanism.available[] +``` + +**`LineItemSummary`** adds this key: -**Summary Representation** — what a buying agent reads when screening many -sellers' shelves. A catalog response MAY return it. It MUST contain, per line -item: +| 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 +``` -`line_item_id` · `channel` · `content_detail` · property names · -`formats.included[]` · `pricing.cost_method` · `pricing.gross_rate` · -`min_spend` · `addressable_scale` · `committed_metrics[]` · -`transaction_mechanism` +**`ProposalSummary`** adds this key: -plus, from the parent proposal: `proposal_id` · `seller_name` · `description` · -`valid_from` · `valid_until`. +| 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 | -`catalog_ref` values MUST remain unresolved in a summary. +### 2.2 Shared types -**Full Representation** — every field, with all `catalog_ref` values resolved. -A full fetch MUST return it. +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. -### 2.2 Catalog references +| 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 within the limits of § 4.1.1 | Money. It carries no currency: § 5.6 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. On a `dooh` line item that is each screen's own zone, which its `time_zone` names when present | +| `Url` | String: an absolute `http` or `https` URL ([RFC 3986](https://www.rfc-editor.org/rfc/rfc3986)) | A location a client can fetch | +| `HttpsUrl` | A `Url` whose scheme is `https` | A location fetched over TLS | +| `FieldPointer` | String: a JSON Pointer ([RFC 6901](https://www.rfc-editor.org/rfc/rfc6901)) | A location in the object this specification defines | +| `Ext` | `Map[Domain, Object]` | The extension container. § 2.5 states its rules | + +**`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.5) 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` | HttpsUrl | REQUIRED | The document. It MAY need authentication | +| `digest` | Digest | REQUIRED | Over the exact bytes served, with no content coding | -Any field whose value is drawn from a seller-maintained catalog MAY carry a -reference in place of an inline value: +**`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 one outside a proposal carries +`seller_domain`. -``` -catalog_ref: "//@" -example: "examplepub/audiences/smb-decision-makers@3" +| 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 | +| `seller_domain` | RegistrableDomain | REQUIRED when the `VersionRef` is outside its proposal | The proposal's `seller.domain` | + +**`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 message 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 a request fails message +authentication is § 2.8'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 `commit`, `same` keeps the `idempotency_key` and such an error is not a stored result (§ 5.6.1), and `refetch` and `none` take a new `idempotency_key` | + +Core codes: `validation` \| `not_found` \| `not_offered` \| `unresolved_reference` \| +`not_committable` \| `stale_version` \| `expired` \| `idempotency_conflict` \| +`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, § 5.6, § 5.6.1), or a buyer asks for a hold where `hold_terms` offers none (§ 5.6.2) | +| `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), a live `commitment` entry already names a line item (§ 4.1.2), or a seller rejects a `commit` before `offer_window.start` (§ 5.6.1) | +| `stale_version` | An ask is stale (§ 4.1.3) | +| `expired` | `offer_window.end` or `price_valid_until` has passed | +| `idempotency_conflict` | An `idempotency_key` is reused with a different `Commit` (§ 5.6.1) | +| `not_eligible` | The buyer or the sender fails an eligibility or authority check the seller applies (§ 5.8) | +| `record_mismatch` | A `record_digest` in the request does not equal the one the receiver recomputes, and § 5.6.1 gives no other code (§ 4.1.1) | + +An `ErrorObject` is open. A receiver that does not recognize a code, or a key of +the object, acts on `retry`. + +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, each line item, and each `change_requests[]` entry (§ 4.4) 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), `ErrorObject` + (§ 2.4), and `CostParameters` (§ 5.6.5) 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. On a `change_requests[]` entry, it is outside the record, as the + entry is (§ 4.4). +- 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[]`, `evidence[]`, and `negotiation_history[]`, and a name another standard +spells, such as `playmethod[]`. 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`, and § 1.1 maps it to +that list. + +**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, +a change request's `comments`, 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`. + +### 2.8 Web Bot Auth profile + +This section is the OpenProposal profile of [Web Bot Auth](https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/), the IETF +protocol for signing automated HTTP traffic with +[HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421). It selects options +those documents define and adds no header, tag, key field, or error code. A party +that signs an OpenProposal request or response signs it under this profile. Signing +is OPTIONAL: § 4.1.2 states what a signature shows, and `agreed` never depends on one. +When the AAMP agent identity and signing profile (provisional name) is published, it +replaces this section. + +| Rule | Profile choice | +| :--- | :--- | +| Identity | An `https` origin. The signer names it in a covered `Signature-Agent` member, as the ASCII serialization of the origin ([RFC 6454 § 6.2](https://www.rfc-editor.org/rfc/rfc6454#section-6.2)): a lowercase host, no default port, and no path, query, fragment, or userinfo | +| Keys | The origin's key directory, at `/.well-known/http-message-signatures-directory` with the media type `application/http-message-signatures-directory+json`. Each signing key is an Ed25519 key (`kty` `OKP`, `crv` `Ed25519`), and its `kid` is its JWK thumbprint ([RFC 7638](https://www.rfc-editor.org/rfc/rfc7638)) | +| Signature | One signature, with `tag="web-bot-auth"`, `alg="ed25519"`, `keyid` set to the key's thumbprint, `created`, and `expires` no more than 300 seconds after `created`. A request also carries a `nonce` | +| Request components | `@method`, `@target-uri`, `@authority`, and `"signature-agent";key=