Skip to content

§ 5.6.2 — Holds: offered hold terms and the live hold (#66) - #143

Open
KonstantinMirin wants to merge 44 commits into
IABTechLab:mainfrom
KonstantinMirin:op30-holds
Open

KonstantinMirin wants to merge 44 commits into
IABTechLab:mainfrom
KonstantinMirin:op30-holds

Conversation

@KonstantinMirin

Copy link
Copy Markdown

What this changes

Draft 1 keeps a hold's offer and its live state in one object, hold_status. To keep the live part out of the binding record, § 4.1.1 needs a removal path with an exception. This PR splits the object in two.

  • hold_terms, { available, duration }, offers the hold. It is seller-set and inside the record. An absent hold_terms offers no hold, and asking for one returns not_offered (§ 2.4).
  • hold is the buyer's hold. It is lifecycle, outside the record, and absent until the buyer first asks. It carries status, version, position, and valid_until.
  • Two OPTIONAL hold keys state terms: requested_terms, a LineItemWrites, and held_availability, what the seller reserves.
  • HoldStatus adds declined and drops none. The transitions are exhaustive. A declined, expired, or released hold can be asked again, and a re-offer is the listed held → held transition.
  • The held terms are the offered terms of hold.version.
  • hold_scope is removed. Under linked_execution: all, the holds on a proposal move as a unit.

Related issue

Closes #66, a change request from Postindustria (IAB Tech Lab member).

Refs #67 and #134.

Builds on

This PR builds on our buyer writes PR. That PR builds on our lifecycle PR, our parties PR, our errors and extensions PR, and our shared types PR. Through them, it carries #18, #70, #71, and #72 by @Sirajmx, #73, #74, #75, and #76 by @Kishore-2572, and #118 by @aleksUIX. Review the last commit; the earlier ones belong to the PRs it builds on.

Type

  • Editorial — no normative effect
  • Normative — changes what the spec requires
  • Breaking — would break an implementation already built to this draft
  • Examples / schema / docs only

Checklist

  • The body section is updated — § 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
  • Appendix A (field index) matches the body — the hold_status row becomes hold_terms (seller-set) and hold (lifecycle). scripts/check_appendix.py passes, with Availability listed as a pending type
  • Appendix B (fields by marker, and the counts) matches the body — seller-set 38 → 39, lifecycle 1 → 2, mixed 6 → 5; 87 → 88 fields
  • Any affected example in examples/ is updated and still parses — line-item-ctv.yaml offers no hold on its private_auction line item. line-item-display.yaml and proposal-issued.yaml carry hold_terms. proposal-issued.yaml shows a hold granted on version 1 and re-offered at version 2, and its record_digest is recomputed. scripts/check_examples.py passes, and checks requested_terms against the BuyerWrites paths
  • RFC 2119 keywords used deliberately — MUST / SHOULD / MAY are not synonyms
  • The no-inheritance rule (§ 2) still holds — hold_terms and hold sit on the line item
  • Anything selectable declares available[] and included[]; anything settable declares bounds (§ 3.1, § 3.2) — no new selectable or settable fields
  • No confidential or commercially sensitive content

Knock-on effects

Open questions

Open, for #66:

  1. Should a hold stay live under a commit made while it runs? held → expired runs on valid_until alone. A buyer whose commit is unanswered when the hold lapses loses the § 5.6.1 bar on declining.
  2. [Change] § 5.6.2 — hold_status needs refused and re-entry to requested #66 question 4 reads held as reserved inventory, as @EvgenyAndroid and @premesh agree. This PR leaves hold_terms.available as the seller's statement. Should the text state that reading?
  3. Should a seller issue a hold reference, as @EvgenyAndroid asks ([Change] § 5.6.2 — hold_status needs refused and re-entry to requested #66 question 6)? This PR adds none.

By opening this pull request I agree my contribution may be published under this
repository's licence, as set out in CONTRIBUTING.md.

Analyzed and generated with the help of Claude, fully reviewed by me.

Sirajmx and others added 30 commits October 6, 2026 16:28
§ 5.6 defines floor as the seller's offer floor at proposal time, not
the downstream auction floor and not a guarantee that bids will clear.
Editorial: the meaning of the field is unchanged.

Refs IABTechLab#16
A proposal can say its line items must be bought together
(linked_execution, § 4.1), and a line item can name the line items or
spend that must be in the same commitment (requires, § 5.6.3). A bonus
line is a line item with requires and its own price. The § 2 "binding
terms" hook now points at a field, and the Summary Representation
carries has_requires and linked_execution.

Adds a CTV bonus line example (§ 6.5) and linked_execution: none to the
proposal example.

Refs IABTechLab#23
The seller declares which exclusion types it accepts and how it
enforces each (exclusions_accepted); the buyer supplies its lists
(exclusions). Blocked apps carry their store, { platform, bundle },
so one store id is never applied across stores. Exclusions are
recompute inputs (§ 3.3) and are never disclosed to other parties
(§ 5.8). The display example declares and supplies lists.

Refs IABTechLab#25
A line item whose rate differs by market can declare geo with the
selectable structure of § 3.1: available[], included[], pricing deltas
and max_select. A geo that declares only allowed[] stays settable, so
existing payloads are unchanged. When several priced markets are
selected, the highest delta applies. The audio example is priced by
market.

Refs IABTechLab#14
A commitment names the proposal and version it was priced against
(§ 5.6.1). The seller rejects a new attempt on a stale version, or past
valid_until or price_valid_until, and returns the current version. An
authenticated replay with the same idempotency_key and terms returns the
stored result without re-running those checks; the same key with
different terms is a conflict. Keys are opaque and scoped to one buyer
and seller pair. § 4.1 states that each revision while published
increments version.

The display, CTV and audio examples carry proposal_id and version.

Refs IABTechLab#24
§ 4.1 adds an optional, immutable proposal_template_id linking an
issued proposal to the seller's template. Each issued proposal has its
own status, version and negotiation_history[], and buyers never need
the template id to transact. § 7 adds a non-normative note separating
seller-side templates from the issued proposals buyers commit against.

Appendix A gains one row; Appendix B counts immutable 6 → 7 and
72 fields. The proposal example carries a proposal_template_id.

Refs IABTechLab#26
§ 5.8 adds terms_ref ({ url, sha256, version }), REQUIRED when
terms_acceptance_required is true, hash-pinned and frozen at agreed, so
assent binds to an exact document. § 5.10 adds agreement_ref, an
informative pointer to the master or annual agreement a line item is
priced under, and documents the io and agreement roles on
external_references[]. IO generation is stated to be a connector
concern, and § 7 adds a non-normative note on relationships.

agreement_ref is seller-set; a buyer-held agreement id travels as an
external_references[] entry with role: agreement, so the field keeps
one marker (§ 3).

Appendix A gains two rows; Appendix B counts seller-set 29 → 31 and
73 fields. The display example carries terms_ref.

Refs IABTechLab#27
A new § 2.3 lets a seller publish its public standard line items at
/.well-known/openproposal.json: seller_id, generated_at, spec_version
and, per proposal, proposal_id, version, the Summary Representation of
each line item and a full_url. ETag / Last-Modified are required, only
line items with visibility public appear, and entries must match the
current version. Buyers discover from the manifest, then fetch the Full
Representation of shortlisted line items.

Adds examples/manifest.json (§ 6.5) and visibility: public to the CTV
and audio examples; the registry_gated display line item is not listed.

Refs IABTechLab#29
The specification does not define authentication yet (WS1; see IABTechLab#62),
so the replay rule now applies to a replay from the same buyer rather
than an authenticated replay.

Refs IABTechLab#24
agreement_ref.parties used buyer_id, which the specification does not
define. parties[] now names the seller by its seller_id and the buyer
by the id it holds in eligible_bidders[] (section 5.8).

Refs IABTechLab#27
The rule that a hold on a linked_execution: all proposal uses
hold_scope: proposal came from a comment on IABTechLab#23, not the change request
as filed. It moves to the PR description as a point for the working
group, so this PR carries only the filed change.

Refs IABTechLab#23
IABTechLab#25 names the content-category list iab_content_category[], a list of
IAB Content Taxonomy ids. The earlier commit renamed it content_category
with a version-pinned taxonomy_ref, which nobody proposed. This restores
the filed name and shape; which taxonomy version to reference is left
to the working group.

Refs IABTechLab#25
IABTechLab#14 doesn't say how deltas combine when a buyer selects more than one
priced market, and neither did any comment on it. The earlier commit
said the highest delta applies; that is a design choice nobody
proposed, so it moves to the PR description as a question.

Refs IABTechLab#14
Read the media condition from the ad served when an impression accepts
several media, name the seller as the party that serves, place linear TV
outside the table, and state environments as (distribution channel,
device type) pairs. Insert environments.available[] into the summary
list without rewrapping it, shorten the changelog entry and drop
audio_streaming from OQ-7 in docs/open-questions.md.

Refs IABTechLab#90
time_zone is the line item's clock for daypart windows and
frequency_cap.period: an IANA time zone name or viewer_local. A window
is { days[], start, end } in civil time with an exclusive end later
than start, up to 24:00; a window that crosses midnight is two windows.
List the `days[]` values once in the `daypart` row, as `monday` | ... |
`sunday`. Write HH:MM, America/New_York, and UTC as plain text, since
they're a format and examples, not field names. The meaning is
unchanged.
"At least one of" an empty list is never satisfied, so the bonus line
would fail a literal check. An absent key sets no condition. Only the
examples change; the any_of[] row and the rules stay as filed in IABTechLab#23.

Refs IABTechLab#23
The floor row now says it "neither constrains nor describes the
downstream auction floor", as IABTechLab#16 asked, and names that floor: the
per-impression floor the exchange applies at auction time (OpenRTB 2.6
Imp.bidfloor).

Refs IABTechLab#16
From the Postindustria review on IABTechLab#73:
- a replay matches on the same body, every value the buyer sent except
  idempotency_key, and returns the stored result whether an acceptance
  or a rejection; a retry against a new version uses a new key;
- version increments on each revision of the proposal, not only while
  published, so a counter under review also moves it;
- "deterministic" now says what the checks depend on: the stored
  proposal and the time the seller receives the attempt;
- the price check names the pricing[] entry the commitment is priced
  against.

Refs IABTechLab#24
Give each repeated concept one type in section 2.2, and state the table,
presence, type, key, and value rules in section 2.6. Add Type and
Required columns to every field and key table. Add the lifecycle,
recorded, asserted, and mixed markers, and a line_items[] field. Define
the Summary as a strict path subset, with manifest key tables.

Delete catalog_ref. Rename the time, reference, delta, cap, and path
fields to use the shared types. Number every heading, and keep issue
citations and edit history out of normative text. Update the examples,
the manifest, the appendices, and the docs.

Teach check_appendix.py the new columns and a closed layout list, and add
check_examples.py, which checks the examples against the spec's tables.
Add section 2.4: a rejection returns errors[], one or more ErrorObjects
with an open code list, retry values that read for any request, and
informative HTTP fits. Name the code each rejection in sections 5.5,
5.6.1, and 5.6.3 returns, and drop the requires cycle ban.

Add the Ext type and section 2.5: ext on the proposal and each line
item, keyed by owner domain, ignored and kept when not understood, and
frozen at agreed. Make spec_version a required proposal field, and ask
in section 8 how a receiver reads a later release.

Add informative security considerations in section 2.7. Word the open
questions without a count. Update the examples and appendices, and
teach check_examples.py the Ext type and the open ErrorObject.
Make seller a Party in section 4.1, with a required legal_name, and add
buyer and commitment.advertisers[]. Move the registry_id rules to Party
in section 2.2, and scope proposal_id to seller.domain.

Add section 1.2 with the three identity layers. In section 5.8, require
visibility, define each value, and separate who may read from who may
commit. Eligibility is a resolve step that returns not_eligible, and an
unreadable line item returns not_found. A required credential is
{ credential_type, issuer }, and eligible_bidders[] splits into
eligible_buyers[] and eligible_seats[].

Give buyer_requirements, billing, and agreement key tables, and convert
the rows the terms and agreement change added. In section 2.3, drop the
manifest seller_id and filter on status, and ask in section 8 whether a
manifest needs both validators. Update the examples, appendices, and
checkers.
# Conflicts:
#	examples/proposal.yaml
#	spec/openproposal-3.0.md
Separate shared and issued proposals, and add issued_from. Define the
binding record, record_digest, and assent[] entries, freeze a version's
offered terms, and state one staleness rule for asks and holds.

Give each act one history action, name the status types, make the
line-item transitions exhaustive, define a firm entry per pricing entry,
and list the allowed decline grounds once. State agreed once.

Remove buyer state from the shared examples, add an issued, partly
agreed example, and recompute its record_digest in the checker.
Add selected[] as the complete set of options in effect and value as the
one buyer key on a settable field, with an explicit default. An envelope's
value narrows allowed[], and the bare-value fallbacks go. Every object
option has an id, unique in its field. Name BuyerWrites, LineItemWrites,
and the availability read.

Give agreed_rate one formula, on the committed entry only, rounded once.
Rename gross_rate to list_rate, floor to min_rate, a hint, and delta_pct
to delta_fraction, a fraction. Make list_rate and commitment_bounds
requestable, mark commitment mixed, and give transaction_mechanism
max_select 1.

Split geo into a settable geo and a selectable markets over GeoCode, type
device as AdCOM codes and daypart as ClockWindow, and delete
supported_formats. Update the examples, the issued record_digest, the
appendices, and the example checker.
Replace hold_status with hold_terms, a seller-set offer inside the binding
record, and hold, live state outside it, so the removal path has no
exception. Rename state and hold_expires_at to status and valid_until, add
position, the order in which holds are granted, and leave hold absent until
the buyer first asks.

Close the hold machine: add declined, re-entry to requested, and the
re-offer as listed transitions, and state the cap on valid_until. Give hold
a version and two optional keys, requested_terms as LineItemWrites and
held_availability as Availability, which stays pending until its key table
lands. Remove hold_scope: under linked_execution all, holds move as a unit.

Stop the CTV example offering a hold on private_auction, show a full hold in
the issued example, and recompute its digest. Drop the checker's handling of
exceptions in removal paths, and check the keys of LineItemWrites.

Refs IABTechLab#66
@premesh

premesh commented Nov 4, 2026

Copy link
Copy Markdown

Position reads right. One thing from #66 didn't carry: first refusal on renewal. It isn't a queue position; it's the current advertiser's right to the next cycle before it opens up. I'd keep it as an offered term on the line item, renewal_right: first_refusal | none, versioned like any other term.

@EvgenyAndroid

Copy link
Copy Markdown

Agree with @premesh on renewal_right. It's negotiated, so it belongs on the line item as an offered term inside the record, versioned like hold_terms, and not in hold.

On the open questions:

  1. Yes, keep the hold live under an unanswered commit. Otherwise a seller can leave a commit unanswered until valid_until passes, then decline it for availability, which is what the hold was meant to rule out. It's the same silence-as-decline gap I raised on [Change] § 4, § 5.1 — Add § 4.3 Lifecycle: who moves each status #65. held → expired should wait until the commit is answered.
  2. Yes, state it: held means reserved inventory. A seller that only honours terms sets hold_terms.available: false and relies on price_valid_until.
  3. Yes to a hold reference. held_availability says how much is held, not which reservation it is, so a buyer still can't reconcile a hold against the seller's systems. Because hold is lifecycle, a seller-issued reference adds no version and doesn't change the record_digest.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Change] § 5.6.2 — hold_status needs refused and re-entry to requested

6 participants