From cb0ea9d135d29997197df187575f452562609236 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 6 Nov 2026 00:54:00 +0000 Subject: [PATCH] fix(schema): keep sla-window at its 3.2 shape; business time moves to creative review SLA #8501 entries 11 and 12. core/sla-window.json is held closed with the commercial terms until 4.0 (DR-0036), and 3.3.0-beta.1 added clock and business_calendar_id to it, so a strict 3.2 validator rejected a 3.3 SLA. sla-window goes back to its 3.2.8 publication (only $id differs). The signed terms reach it through change_terms[].processing_sla, and 3.2.8 says sellers include weekends and non-working periods in the maximum. clock and business_calendar_id move to core/creative-review-sla.json, which no longer composes sla-window: it defines its own durations, keeps completion_max required and the business-clock calendar rule, and is open. escalation-sla loses clock. An action's sla, a change term's processing_sla and the escalation SLA are elapsed time only. Tests pin sla-window to 3.2.8 and check the business clock is rejected on the four action schemas, the change term and the escalation SLA. The two sla-window baseline rows go, leaving 2. The MCP parity bound moves to 1_388_000 (measured 1_387_457 on main after #8866). Docs, the migration guide and a minor changeset name where the fields live now. Refs #8501 Co-Authored-By: Claude Opus 5.5 --- .changeset/creative-review-business-clock.md | 11 +++ docs/media-buy/product-discovery/index.mdx | 3 +- .../proposal-negotiation.mdx | 2 +- docs/media-buy/service-commitments.mdx | 20 ++++-- docs/reference/migration/3-2-to-3-3.mdx | 1 + docs/reference/release-notes.mdx | 2 +- docs/reference/whats-new-in-3-3.mdx | 2 +- scripts/lint-additive-minor-baseline.json | 16 +---- .../source/core/creative-review-sla.json | 52 +++++++++++++- .../schemas/source/core/escalation-sla.json | 5 +- static/schemas/source/core/sla-window.json | 41 ++---------- .../schemas/source/core/x-entity-types.json | 2 +- static/schemas/source/index.json | 4 +- .../schemas/source/media-buy/change-term.json | 2 +- .../get-adcp-capabilities-response.json | 2 +- tests/mcp-schema-projection.test.cjs | 5 +- tests/service-commitments.test.cjs | 67 +++++++++++++++++-- 17 files changed, 154 insertions(+), 83 deletions(-) create mode 100644 .changeset/creative-review-business-clock.md diff --git a/.changeset/creative-review-business-clock.md b/.changeset/creative-review-business-clock.md new file mode 100644 index 00000000000..ddcfd403bdf --- /dev/null +++ b/.changeset/creative-review-business-clock.md @@ -0,0 +1,11 @@ +--- +"adcontextprotocol": minor +--- + +Keep `core/sla-window.json` at its 3.2.8 shape and move business time onto the creative review SLA (adcp#8501, entries 11 and 12). `sla-window` stays closed with the commercial terms until 4.0 (DR-0036), and 3.3.0-beta.1 added `clock` and `business_calendar_id` to it, so a strict 3.2 validator rejected a 3.3 SLA. The signed terms use `sla-window` through `change_terms[].processing_sla`, and 3.2.8 says sellers include weekends and non-working periods in the maximum, so the signed SLA stays elapsed time. + +**`clock` and `business_calendar_id` now live on `core/creative-review-sla.json`.** `creative_policy.review_sla` keeps business time: `clock` defaults to `elapsed`, `clock: "business"` requires a `business_calendar_id` that resolves to `get_adcp_capabilities.business_calendars[]`, and the calendar and snapshot rules are unchanged. The schema no longer composes `sla-window`. It defines `response_max` and `completion_max` itself, keeps `completion_max` required, and is open. + +**Every other SLA is elapsed time, as in 3.2.** An action's `sla` on `allowed_actions[]` and `available_actions[]`, a change term's `processing_sla`, and `core/escalation-sla.json` reject `clock` and `business_calendar_id`. `escalation-sla` loses the `clock: "elapsed"` it accepted in the betas. + +**This changes 3.3.0-beta.1 through beta.5.** The betas are immutable. A seller or SDK built from one stops sending `clock` and `business_calendar_id` anywhere except `creative_policy.review_sla` and the `review_sla` on a creative action. The 3.3 release notes, What's new, the 3.2 to 3.3 migration guide, and the service commitments page are updated. diff --git a/docs/media-buy/product-discovery/index.mdx b/docs/media-buy/product-discovery/index.mdx index 70b59140262..a1677eb0e23 100644 --- a/docs/media-buy/product-discovery/index.mdx +++ b/docs/media-buy/product-discovery/index.mdx @@ -195,8 +195,7 @@ envelope use [`control_media_buy`](/docs/media-buy/task-reference/control_media_ When the seller offers post-acceptance change rights, it places them in `commercial_terms.change_terms[]`. Each entry names one canonical action, its -service mode, lifecycle statuses, an optional processing SLA (elapsed by default, -or explicit business time using a declared calendar), +service mode, lifecycle statuses, an optional processing SLA in elapsed time, portable constraints, stable conditions, and an optional contract reference. Because the array is inside `commercial_terms`, the proposal `terms_digest` binds those rights. When the array is present, an omitted action was not diff --git a/docs/media-buy/product-discovery/proposal-negotiation.mdx b/docs/media-buy/product-discovery/proposal-negotiation.mdx index 5307a8c2162..3ab61d64a93 100644 --- a/docs/media-buy/product-discovery/proposal-negotiation.mdx +++ b/docs/media-buy/product-discovery/proposal-negotiation.mdx @@ -562,7 +562,7 @@ The protocol layer owns schema validation, capability gates, result cardinality, Fence `ask` from pricing and authorization authority. It is attacker-controlled free text and may influence candidate generation only through bounded application policy. Only the typed `commercial_terms` object becomes contractual. -Post-acceptance change rights belong in `commercial_terms.change_terms[]`, not in `ask`, product prose, or an unbound capability. The seller emits at most one term per canonical action. `service_mode` describes the buyer-visible route; `allowed_statuses` defines the non-terminal lifecycle states in which the contractual right applies; and `processing_sla.response_max` and `completion_max` default to elapsed-time maxima that already account for weekends and non-working periods. An explicit `clock: "business"` uses the immutable calendar declared by `business_calendar_id`; see [Service commitments](/docs/media-buy/service-commitments) for deadline calculation and snapshot rules. `seller_managed` means the seller controls the internal decision process without exposing whether it used automation or people. The legacy `requires_approval` value remains readable for 3.x compatibility. +Post-acceptance change rights belong in `commercial_terms.change_terms[]`, not in `ask`, product prose, or an unbound capability. The seller emits at most one term per canonical action. `service_mode` describes the buyer-visible route; `allowed_statuses` defines the non-terminal lifecycle states in which the contractual right applies; and `processing_sla.response_max` and `completion_max` are elapsed-time maxima that already account for weekends and non-working periods. A processing SLA has no business clock; only a product's creative review SLA can count working time (see [Service commitments](/docs/media-buy/service-commitments)). `seller_managed` means the seller controls the internal decision process without exposing whether it used automation or people. The legacy `requires_approval` value remains readable for 3.x compatibility. The complete array is covered by `terms_digest`. If `change_terms` is present, an unlisted action is not a negotiated right; if the whole field is absent, consumers retain legacy behavior and consult current `available_actions[]`. Runtime `available_actions[]` may narrow a negotiated right because of current state or a stated condition, but MUST NOT silently broaden the accepted terms or replace the negotiated mode, SLA, constraints, conditions, or contract reference. A broader right requires an accepted amendment proposal. diff --git a/docs/media-buy/service-commitments.mdx b/docs/media-buy/service-commitments.mdx index d7f62d8cf76..6683252e0aa 100644 --- a/docs/media-buy/service-commitments.mdx +++ b/docs/media-buy/service-commitments.mdx @@ -24,8 +24,9 @@ combine business durations from different calendars without computing a common b ## Creative review -`creative_policy.review_sla` uses the existing SLA window with a required -`completion_max`. Completion means an **approved or rejected decision** for a +`creative_policy.review_sla` is a creative review SLA with a required +`completion_max`, in elapsed time by default or in business time on a declared +calendar. Completion means an **approved or rejected decision** for a creative in an accepted format. Ingestion, `processing`, `pending_review`, and `suspended` do not complete review. Every applicable approval scope must reach an approved or rejected decision; a partially approved assignment with pending @@ -64,11 +65,18 @@ alone cannot authorize spend. ## Business-time calendars -`SLAWindow.clock` defaults to `elapsed`, including weekends and non-working time. -An explicit `business` clock counts only working time and MUST reference exactly -one `get_adcp_capabilities.business_calendars[]` entry by `business_calendar_id`. +Only the creative review SLA carries a clock. Its `clock` defaults to `elapsed`, +including weekends and non-working time. An explicit `business` clock counts only +working time and MUST reference exactly one +`get_adcp_capabilities.business_calendars[]` entry by `business_calendar_id`. Missing or unresolved calendars invalidate the commitment; never fall back to elapsed time. +Every other SLA is elapsed time, as in 3.2: an action's `sla`, a change term's +`processing_sla`, and the escalation SLA. They use `core/sla-window.json`, which +stays closed with the commercial terms until 4.0, so they reject `clock` and +`business_calendar_id`, and sellers include weekends and non-working periods in +the maximum. + A calendar declares an IANA time zone, weekly working periods, complete non-working dates, a coverage interval, and working minutes per business day: @@ -127,7 +135,7 @@ Duration filters MUST require presence and account for `clock`: compare UTC deadlines for the same receipt time across business and elapsed commitments. Without calendar support, treat business SLAs as unknown for duration matching. -For a peer pinned below 3.3, sellers MUST omit the entire business-clock SLA or +For a peer pinned below 3.3, sellers MUST omit the entire business-clock review SLA or translate it to a conservative elapsed bound that holds for every request to which that declaration applies. Dropping only `clock` and `business_calendar_id` silently changes the promise and is forbidden. Business diff --git a/docs/reference/migration/3-2-to-3-3.mdx b/docs/reference/migration/3-2-to-3-3.mdx index 909a55dff34..4a763af4e37 100644 --- a/docs/reference/migration/3-2-to-3-3.mdx +++ b/docs/reference/migration/3-2-to-3-3.mdx @@ -412,6 +412,7 @@ What this means for 3.3 adopters: - **Generated types can change.** A generator that reads `additionalProperties` treats the opened objects as open: json-schema-to-typescript adds an index signature, and Pydantic models generated with `extra="forbid"` stop rejecting extra fields. Code that relied on a type or validation error for an unknown key needs its own check. - **Send a 3.3 field only to a 3.3 peer.** A strict validator on 3.2.9 or earlier still rejects it ([Send only to 3.3 peers](#send-only-to-3-3-peers)). - **Features that needed new terms in `commercial_terms` stay withdrawn** ([above](#withdrawn-from-3-3-planned-for-4-0)), and seller-held change terms are planned for 4.0 instead of shipping as `commercial_terms.change_terms[]`. +- **Only the creative review SLA carries a clock** ([adcp#8501](https://github.com/adcontextprotocol/adcp/issues/8501)). `core/sla-window.json` is back to its 3.2.8 shape, and `clock` and `business_calendar_id` now live on `core/creative-review-sla.json`, which `creative_policy.review_sla` uses. Sellers built on 3.3.0-beta.1 through beta.5 stop sending `clock` and `business_calendar_id` on an action's `sla`, a change term's `processing_sla`, and the escalation SLA. Those maxima are elapsed time and include weekends and non-working periods. ### Capabilities are bounded by structure diff --git a/docs/reference/release-notes.mdx b/docs/reference/release-notes.mdx index c5332a0db35..f10b2c77b0d 100644 --- a/docs/reference/release-notes.mdx +++ b/docs/reference/release-notes.mdx @@ -47,7 +47,7 @@ Each beta is immutable. The `3.3-beta` documentation selector advances to the la - **Envelope and negotiation:** `core/protocol-envelope.json` composed on every task response, with `status` required and one definition each for `message` and `task_id` (#8547); a request carrying only `adcp_major_version` is served the oldest supported release in that major, 3.0 where supported (DR-0034); the schemas that were meant to be open opened, starting with the 3.2 proposal flow (#8531), and the rest stay closed where closure enforces something, with `commercial_terms` held closed until 4.0 (DR-0036); the buyer's registration selects the webhook-signing mode (#8475); condition (b) of `request_signature_required` deprecated and JSON-RPC batch bodies rejected (#8064). - **Capabilities:** every array, string, and keyed map in the `get_adcp_capabilities` response is bounded by structure, and the 64 KiB byte cap is a receiver backstop, not a design target (#8505, #8526). - **Principals:** a principal is the buyer agent as the seller has bound it, so `principal_kind` and `expected_principal_kind` are deprecated (#8443); one delegation-chain rule for demand-side grants (#8473). -- **Service commitments and records:** product creative-review timing, business-clock SLA windows, and the `report_media_buy_issue` and `get_media_buy_issue` contracts (#8417); package retention on canceled and completed buys (#8467); an optional display `name` on measurement catalog metrics (#8437); an optional `metric_id` on performance standards (#8436); `setup_url` accepts query strings and percent-encoding (#8548). +- **Service commitments and records:** product creative-review timing in elapsed or business time on seller calendars, and the `report_media_buy_issue` and `get_media_buy_issue` contracts (#8417); package retention on canceled and completed buys (#8467); an optional display `name` on measurement catalog metrics (#8437); an optional `metric_id` on performance standards (#8436); `setup_url` accepts query strings and percent-encoding (#8548). - **Platform identifiers:** `platform_id` on `sync_event_sources` and `sync_audiences` results, with deprecated `seller_id` aliases retained throughout 3.x. Sellers reporting an ID send both with identical values; buyers prefer `platform_id` and fall back to `seller_id` when absent (#8290). - **Signals:** optional `age_determination_bases` on signal listings, definitions, and enrichment, so a signal can say whether its members' ages are `verified`, `declared`, or `inferred` (#8145). Stable in 3.3; 3.2 integrations carry it as `ext.adcp.age_determination_bases`. Experimental `signal_status` and `source_audience_id` on `get_signals` results (#8036). Experimental signal pricing groups, `signals.pricing_groups` (#8058), and provider-initiated delivery to agent destinations, `signals.delivery_methods` (#8044). - **Property lists:** enforceable `domain` / `subdomain` identifier formats, `get_property_list` reference resolution with list-scoped bearer credentials, and a registry sandbox (#8281, #8282). diff --git a/docs/reference/whats-new-in-3-3.mdx b/docs/reference/whats-new-in-3-3.mdx index 115ecdd5374..28b9adaf203 100644 --- a/docs/reference/whats-new-in-3-3.mdx +++ b/docs/reference/whats-new-in-3-3.mdx @@ -92,7 +92,7 @@ These shipped before beta.3 and are summarized here with the rest. Everything ma - **Undisclosed rosters ([#7948](https://github.com/adcontextprotocol/adcp/pull/7948), beta.1).** A product's `publisher_properties` or `collections` can be `[]`, which means the seller does not disclose the roster. There is no new field and no partial state. Allow lists may be accepted against it and cover only what the seller matches. Exclusion lists are the buyer's choice, because the buyer sees the undisclosed roster at discovery: a seller that accepts one applies it to its full internal roster, and a seller that cannot rejects it with `UNSUPPORTED_FEATURE`. A seller serving a release below 3.3 never returns such a product. Buyers treat the roster as opaque and may refuse the product. See [Roster coverage](/docs/media-buy/product-discovery/media-products#roster-coverage). - **Canceled and completed buys keep their packages ([#8467](https://github.com/adcontextprotocol/adcp/pull/8467), beta.1).** From 3.3 a seller retains packages on terminal buys, with stable identities, platform mappings, and the latest committed configuration, and discloses irrecoverable history through nonfatal errors instead of returning an incomplete list. - **Mixed creative approval states at startup ([#8485](https://github.com/adcontextprotocol/adcp/pull/8485), beta.1).** A seller keeps pending-review and rejected assignments and starts the approved ones by default. The optional package `start_requires_all_creatives_approved` gate holds the start until every assignment is approved, and `media_buy.features.creative_start_policy` declares support. -- **Review and escalation commitments ([#8417](https://github.com/adcontextprotocol/adcp/pull/8417), beta.1).** Products can declare creative-review timing and business-clock SLA windows with seller calendars. `report_media_buy_issue` and `get_media_buy_issue` give a human acknowledgement and resolution status for a critical report. +- **Review and escalation commitments ([#8417](https://github.com/adcontextprotocol/adcp/pull/8417), beta.1).** Products can declare creative-review timing in elapsed time or in business time on a seller calendar. Every other SLA stays elapsed time, as in 3.2. `report_media_buy_issue` and `get_media_buy_issue` give a human acknowledgement and resolution status for a critical report. - **Seller offers (experimental, `media_buy.seller_offers`, [#8102](https://github.com/adcontextprotocol/adcp/pull/8102), beta.2).** A seller can propose a change to an accepted buy: a postponed or canceled installment, a preemption, under-delivery. The offer is a `committed` canonical proposal with `proposal_initiator: "seller"` and an `offer_reason`, read through `list_proposals` and announced by the identifiers-only `proposal.offered` notification. The buyer accepts, declines, or revises it with the existing tasks. At `expires_at` the signed terms stand, and silence is never acceptance. Sellers never send offers to callers below 3.3. See [Seller offers](/docs/media-buy/product-discovery/proposal-negotiation#seller-offers). - **Price adjustments (experimental, `media_buy.price_adjustments`, [#7955](https://github.com/adcontextprotocol/adcp/pull/7955), beta.1).** A fixed-price pricing option can publish an ordered table of fee and discount rows keyed by format option, placement selection, or always, so a buyer computes the price of what it selects. The table is optional, and its resolution order and rounding are normative. See [Selection-dependent price adjustments](/docs/media-buy/advanced-topics/pricing-models#selection-dependent-price-adjustments). - **Execution readiness (experimental, `media_buy.execution_readiness`, [#7934](https://github.com/adcontextprotocol/adcp/pull/7934), beta.1).** A `get_products` request with `account` can set `include_execution_readiness: true` to learn, per product and per `execution_requirements` entry, whether the account is `ready`, `setup_required`, `selection_required`, `input_required`, or `unknown`, with candidates whose `resource_id` fits the existing binding field. See [Per-account execution readiness](/docs/media-buy/specification#per-account-execution-readiness-experimental). diff --git a/scripts/lint-additive-minor-baseline.json b/scripts/lint-additive-minor-baseline.json index 8ce64033d19..311c5241410 100644 --- a/scripts/lint-additive-minor-baseline.json +++ b/scripts/lint-additive-minor-baseline.json @@ -1,5 +1,5 @@ { - "$comment": "Non-additive schema changes recorded by scripts/lint-additive-minor.cjs, relative to the release named in base. This list only shrinks: CI rejects an entry that the target branch does not have. Remove an entry when its change is reverted or released, and record the decision in reason. A property-added-to-closed entry records an addition to a schema that stays closed in 3.x; all four current entries are in commercial-terms schemas, which are held closed until 4.0 (DR-0036).", + "$comment": "Non-additive schema changes recorded by scripts/lint-additive-minor.cjs, relative to the release named in base. This list only shrinks: CI rejects an entry that the target branch does not have. Remove an entry when its change is reverted or released, and record the decision in reason. A property-added-to-closed entry records an addition to a schema that stays closed in 3.x; the one current entry is in a commercial-terms schema, which is held closed until 4.0 (DR-0036).", "base": "3.2.9", "entries": [ { @@ -9,20 +9,6 @@ "detail": "property metric_id added", "reason": "Kept closed: part of the signed commercial terms (DR-0036). metric_id selects which vendor number decides a signed performance standard, so a 3.2 buyer would read the same standard differently. #8501 entry 6 withdraws it from 3.3 and removes this entry." }, - { - "file": "core/sla-window.json", - "pointer": "", - "kind": "property-added-to-closed", - "detail": "property business_calendar_id added", - "reason": "Kept closed: part of the signed commercial terms through change_terms[].processing_sla (DR-0036). #8762 (#8501 entry 12) moves business_calendar_id to core/creative-review-sla.json and removes this entry." - }, - { - "file": "core/sla-window.json", - "pointer": "", - "kind": "property-added-to-closed", - "detail": "property clock added", - "reason": "Kept closed: part of the signed commercial terms through change_terms[].processing_sla (DR-0036). A business clock changes what a signed maximum means. #8762 (#8501 entry 11) moves clock to core/creative-review-sla.json and removes this entry." - }, { "file": "core/x-entity-types.json", "pointer": "", diff --git a/static/schemas/source/core/creative-review-sla.json b/static/schemas/source/core/creative-review-sla.json index a96110ffe8f..ea7cceada85 100644 --- a/static/schemas/source/core/creative-review-sla.json +++ b/static/schemas/source/core/creative-review-sla.json @@ -2,8 +2,54 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/creative-review-sla.json", "title": "Creative Review SLA", - "description": "Time from seller receipt of a creative review submission to an approved or rejected decision for a format the product accepts. Ingestion, processing, pending_review, and suspended are not terminal decisions. Applies to each accepted review submission independently. Idempotent replay does not restart the clock; changed content requiring review starts a new clock. Absence means no review commitment.", + "description": "Time from seller receipt of a creative review submission to an approved or rejected decision for a format the product accepts. Ingestion, processing, pending_review, and suspended are not terminal decisions. Applies to each accepted review submission independently. Idempotent replay does not restart the clock; changed content requiring review starts a new clock. Absence means no review commitment. Elapsed time includes non-working time (P1D = PT24H). Business time uses the referenced calendar's working periods and business_day_minutes for P1D. Compare clocks using deadlines for the same receipt time. Only creative review carries a business clock; other SLA windows are elapsed time.", "x-added-in": "3.3.0", - "allOf": [{ "$ref": "/schemas/core/sla-window.json" }], - "required": ["completion_max"] + "type": "object", + "properties": { + "clock": { + "type": "string", + "enum": ["elapsed", "business"], + "default": "elapsed", + "description": "Business time starts at seller receipt, advancing to the next working period when outside working time." + }, + "business_calendar_id": { + "type": "string", + "minLength": 1, + "x-entity": "business_calendar", + "description": "Immutable seller-local ID in get_adcp_capabilities.business_calendars; required only for business time." + }, + "response_max": { + "type": "string", + "description": "ISO 8601 bound from seller receipt of the review submission to the seller acknowledging it for review.", + "pattern": "^P(?!$)(\\d+Y)?(\\d+M)?(\\d+D)?(T(\\d+H)?(\\d+M)?(\\d+S)?)?$", + "examples": ["PT5M", "PT4H"] + }, + "completion_max": { + "type": "string", + "description": "ISO 8601 bound from seller receipt of the review submission to an approved or rejected decision.", + "pattern": "^P(?!$)(\\d+Y)?(\\d+M)?(\\d+D)?(T(\\d+H)?(\\d+M)?(\\d+S)?)?$", + "examples": ["PT24H", "P1D", "P2D"] + } + }, + "required": ["completion_max"], + "allOf": [ + { + "if": { "properties": { "clock": { "const": "business" } }, "required": ["clock"] }, + "then": { + "required": ["business_calendar_id"], + "properties": { + "response_max": { "pattern": "^P(?!$)(?:[0-9]+D)?(?:T(?=[0-9])(?:[0-9]+H)?(?:[0-9]+M)?(?:[0-9]+S)?)?$" }, + "completion_max": { "pattern": "^P(?!$)(?:[0-9]+D)?(?:T(?=[0-9])(?:[0-9]+H)?(?:[0-9]+M)?(?:[0-9]+S)?)?$" } + } + }, + "else": { "not": { "required": ["business_calendar_id"] } } + } + ], + "additionalProperties": true, + "x-adcp-validation": { + "verifier_constraints": { + "calendar_reference": "A business_calendar_id resolves to exactly one immutable get_adcp_capabilities.business_calendars declaration whose coverage includes receipt and the computed deadline. Invalid or unresolved calendars never fall back to elapsed time.", + "request_snapshot": "Pending reviews retain the SLA and calendar resolved at receipt. Maxima are independent upper bounds; a completion maximum shorter than the response maximum does not invalidate either commitment." + } + } } diff --git a/static/schemas/source/core/escalation-sla.json b/static/schemas/source/core/escalation-sla.json index a99f98163e4..1d0ab73d112 100644 --- a/static/schemas/source/core/escalation-sla.json +++ b/static/schemas/source/core/escalation-sla.json @@ -12,11 +12,8 @@ "response_max" ], "properties": { - "clock": { - "type": "string", - "const": "elapsed" - }, "response_max": { + "description": "Maximum elapsed time from seller receipt of the report to human acknowledgement, including weekends and non-working periods.", "pattern": "^P(?!$)(?:[0-9]+D)?(?:T(?=[0-9])(?:[0-9]+H)?(?:[0-9]+M)?(?:[0-9]+S)?)?$" }, "completion_max": false diff --git a/static/schemas/source/core/sla-window.json b/static/schemas/source/core/sla-window.json index 6df77620a9f..b0eab1df074 100644 --- a/static/schemas/source/core/sla-window.json +++ b/static/schemas/source/core/sla-window.json @@ -2,54 +2,21 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/sla-window.json", "title": "SLA Window", - "description": "Optional commitment; absence means none, not zero. Duration predicates MUST require presence. Elapsed time includes non-working time (P1D = PT24H). Business time uses the referenced calendar's working periods and business_day_minutes for P1D. Compare clocks using deadlines for the same receipt time.", + "description": "Service-level commitment a seller declares for a given action. Optional throughout: absence means no commitment, not zero commitment — buyers querying SLA fields via #4425's `requires` predicate grammar MUST compose with `present` to avoid silently matching sellers who never declared one. Durations are ISO 8601 duration strings (e.g., `PT4H`, `PT24H`, `P1D`). Predicate evaluators that compare durations MUST normalize before comparison; `P1D` and `PT24H` are equal.", "type": "object", "properties": { - "clock": { - "type": "string", - "enum": ["elapsed", "business"], - "default": "elapsed", - "x-added-in": "3.3.0", - "description": "Business time starts at seller receipt, advancing to the next working period when outside working time." - }, - "business_calendar_id": { - "type": "string", - "minLength": 1, - "x-entity": "business_calendar", - "x-added-in": "3.3.0", - "description": "Immutable seller-local ID in get_adcp_capabilities.business_calendars; required only for business time." - }, "response_max": { "type": "string", - "description": "ISO 8601 acknowledgement bound: synchronous response for self_serve, tolerance decision for conditional_self_serve, queue receipt for seller_managed/requires_approval. Elapsed action SLAs start at buyer issuance. Review and report SLAs start at seller receipt; reports require human acknowledgement.", + "description": "Maximum elapsed time from when the buyer issues the action to when the seller acknowledges receipt (mode-appropriate: synchronous response for self_serve, tolerance decision for conditional_self_serve, or queue acknowledgement for seller_managed and legacy requires_approval). Sellers include weekends and non-working periods in the maximum. ISO 8601 duration.", "pattern": "^P(?!$)(\\d+Y)?(\\d+M)?(\\d+D)?(T(\\d+H)?(\\d+M)?(\\d+S)?)?$", "examples": ["PT5M", "PT4H", "P1D"] }, "completion_max": { "type": "string", - "description": "ISO 8601 completion bound: mutation applied, proposal finalized, or seller-managed decision resolved. Elapsed action SLAs start at buyer issuance. Review SLAs start at seller receipt and complete at an approved or rejected decision.", + "description": "Maximum elapsed time from buyer issuing the action to the seller completing it (mutation applied, proposal finalized, or seller-managed decision resolved). Sellers include weekends and non-working periods in the maximum. ISO 8601 duration.", "pattern": "^P(?!$)(\\d+Y)?(\\d+M)?(\\d+D)?(T(\\d+H)?(\\d+M)?(\\d+S)?)?$", "examples": ["PT1H", "PT24H", "P2D"] } }, - "allOf": [ - { - "if": { "properties": { "clock": { "const": "business" } }, "required": ["clock"] }, - "then": { - "required": ["business_calendar_id"], - "properties": { - "response_max": { "pattern": "^P(?!$)(?:[0-9]+D)?(?:T(?=[0-9])(?:[0-9]+H)?(?:[0-9]+M)?(?:[0-9]+S)?)?$" }, - "completion_max": { "pattern": "^P(?!$)(?:[0-9]+D)?(?:T(?=[0-9])(?:[0-9]+H)?(?:[0-9]+M)?(?:[0-9]+S)?)?$" } - } - }, - "else": { "not": { "required": ["business_calendar_id"] } } - } - ], - "additionalProperties": false, - "x-adcp-validation": { - "verifier_constraints": { - "calendar_reference": "A business_calendar_id resolves to exactly one immutable get_adcp_capabilities.business_calendars declaration whose coverage includes receipt and the computed deadline. Invalid or unresolved calendars never fall back to elapsed time.", - "request_snapshot": "Pending requests retain the SLA and calendar resolved at receipt. Maxima are independent upper bounds; a completion maximum shorter than the response maximum does not invalidate either commitment." - } - } + "additionalProperties": false } diff --git a/static/schemas/source/core/x-entity-types.json b/static/schemas/source/core/x-entity-types.json index 17b6bed8484..834bc7af9fd 100644 --- a/static/schemas/source/core/x-entity-types.json +++ b/static/schemas/source/core/x-entity-types.json @@ -96,7 +96,7 @@ "operator_unit": "An operator-owned buying context beneath one operator domain, such as a business unit, agency seat, or platform account. Identity is the tuple `(operator domain, operator_unit.id)`; `operator_unit.name` is mutable display metadata and never participates in identity.", "media_buy": "A media buy / campaign. `media_buy_id` across media-buy/* schemas.", "media_buy_issue": "A seller-owned operational report against a media buy. issue_id round-trips between report_media_buy_issue and get_media_buy_issue within the authorized account and media buy.", - "business_calendar": "An immutable seller working-time declaration. calendar_id from get_adcp_capabilities.business_calendars is referenced by SLAWindow.business_calendar_id.", + "business_calendar": "An immutable seller working-time declaration. calendar_id from get_adcp_capabilities.business_calendars is referenced by CreativeReviewSLA.business_calendar_id.", "package": "A line item within a media buy. `package_id` across media-buy/* schemas.", "product": "A seller inventory product. `product_id` across media-buy/* and core/product.json.", "proposal": "A seller-issued immutable media-plan snapshot. `proposal_id` is returned by request_proposals or refine_proposals; drafts are revised or finalized through refine_proposals, while committed snapshots are consumed by accept_proposal or the create_media_buy compatibility facade. Scoped to the issuing seller and authenticated principal.", diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index ac0c20c563d..260e2a816a9 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -79,11 +79,11 @@ }, "business-calendar": { "$ref": "/schemas/core/business-calendar.json", - "description": "Immutable working-time declaration for business-clock service commitments" + "description": "Immutable working-time declaration for business-clock creative review commitments" }, "creative-review-sla": { "$ref": "/schemas/core/creative-review-sla.json", - "description": "Product-scoped commitment to an approved or rejected creative review decision" + "description": "Product-scoped commitment to an approved or rejected creative review decision, in elapsed or business time" }, "escalation-sla": { "$ref": "/schemas/core/escalation-sla.json", diff --git a/static/schemas/source/media-buy/change-term.json b/static/schemas/source/media-buy/change-term.json index d8d56895f92..e797814f872 100644 --- a/static/schemas/source/media-buy/change-term.json +++ b/static/schemas/source/media-buy/change-term.json @@ -34,7 +34,7 @@ }, "processing_sla": { "$ref": "/schemas/core/sla-window.json", - "description": "Binding acknowledgement and completion commitment. The default elapsed clock includes weekends and non-working periods; an explicit business clock references an immutable seller calendar." + "description": "Binding elapsed-time acknowledgement and completion commitment. Sellers account for weekends and non-working periods when declaring the maximum." }, "conditions": { "type": "array", diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index 92111770e1a..aa440150a39 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -831,7 +831,7 @@ "minItems": 1, "uniqueItems": true, "x-added-in": "3.3.0", - "description": "Seller working-time calendars, uniquely keyed by immutable calendar_id. Every published business-clock SLA MUST resolve to one declaration here. Retain earlier declarations while requests referencing them remain pending. Presence declares calendar support, not any product-level timing commitment.", + "description": "Seller working-time calendars, uniquely keyed by immutable calendar_id. Every published business-clock creative review SLA MUST resolve to one declaration here. Retain earlier declarations while requests referencing them remain pending. Presence declares calendar support, not any product-level timing commitment.", "maxItems": 32 }, "adcp": { diff --git a/tests/mcp-schema-projection.test.cjs b/tests/mcp-schema-projection.test.cjs index 395a2d90776..2df009d6e40 100644 --- a/tests/mcp-schema-projection.test.cjs +++ b/tests/mcp-schema-projection.test.cjs @@ -223,7 +223,10 @@ const MODEL_CONTEXT_BUDGET_KIB = { // bound at 1_386_000. // With both on main (#8911 and the named rejections), the controller measures // 1_386_397 bytes; bound at 1_387_000. -const PARITY_COMPILE_LIMIT = 1_387_000; +// #8501 entries 11-12 restore sla-window to its 3.2.8 text and give the +// creative review SLA its own clock, durations and calendar constraints in +// place of a $ref; the controller measures 1_387_457 bytes, bound at 1_388_000. +const PARITY_COMPILE_LIMIT = 1_388_000; function readJson(filename) { return JSON.parse(fs.readFileSync(filename, 'utf8')); diff --git a/tests/service-commitments.test.cjs b/tests/service-commitments.test.cjs index 1e406ec073e..e1a75bbfcee 100644 --- a/tests/service-commitments.test.cjs +++ b/tests/service-commitments.test.cjs @@ -53,16 +53,42 @@ const calendar = { non_working_dates: ['2027-01-01'], }; -test('legacy elapsed SLA windows retain their wire shape and default clock', async () => { +const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8')); + +test('SLA windows keep their 3.2.8 elapsed-time shape (#8501)', async () => { + const source = readJson(path.join(SOURCE, 'core/sla-window.json')); + const released = readJson(path.join(ROOT, 'dist/schemas/3.2.8/core/sla-window.json')); + const { $id: sourceId, ...sourceRest } = source; + const { $id: releasedId, ...releasedRest } = released; + assert.equal(sourceId, '/schemas/core/sla-window.json'); + assert.match(releasedId, /\/3\.2\.8\/core\/sla-window\.json$/); + assert.deepEqual(sourceRest, releasedRest, 'core/sla-window.json must match its 3.2.8 publication'); + const validate = await compile('core/sla-window.json'); - for (const value of [{}, escalation, { completion_max: 'PT24H' }, { clock: 'elapsed', completion_max: 'P1D' }, { response_max: 'PT4H', completion_max: 'PT2H' }]) accepts(validate, value); - assert.equal(read('core/sla-window.json').properties.clock.default, 'elapsed'); + for (const value of [{}, escalation, { completion_max: 'PT24H' }, { response_max: 'PT4H', completion_max: 'PT2H' }]) accepts(validate, value); rejects(validate, { response_time_minutes: 15 }); + for (const value of [ + { clock: 'elapsed', completion_max: 'P1D' }, + business, + { business_calendar_id: 'review_team_2027_q1', completion_max: 'P1D' }, + ]) rejects(validate, value); +}); + +test('only creative review carries a business clock, which defaults to elapsed', async () => { + const schema = read('core/creative-review-sla.json'); + assert.equal(schema.properties.clock.default, 'elapsed'); + assert.equal(schema.additionalProperties, true, 'a new 3.3 schema defaults to open (DR-0009, DR-0036)'); + assert.ok(!JSON.stringify(schema).includes('sla-window.json'), 'creative review must not compose the closed sla-window'); + const validate = await compile('core/creative-review-sla.json'); + for (const value of [{ completion_max: 'PT24H' }, { clock: 'elapsed', completion_max: 'P1D' }, { response_max: 'PT4H', completion_max: 'PT2H' }]) accepts(validate, value); + accepts(validate, { completion_max: 'PT24H', later_minor_field: true }); + rejects(validate, {}); + rejects(validate, { response_max: 'PT4H' }); rejects(validate, { clock: 'business_days', completion_max: 'P1D' }); }); -test('business SLAs require a calendar and calendar references require an explicit business clock', async () => { - const validate = await compile('core/sla-window.json'); +test('business review SLAs require a calendar and calendar references require an explicit business clock', async () => { + const validate = await compile('core/creative-review-sla.json'); accepts(validate, business); accepts(validate, { ...business, response_max: 'PT4H', completion_max: 'P1DT2H30M15S' }); rejects(validate, { clock: 'business', completion_max: 'P1D' }); @@ -121,10 +147,29 @@ test('creative review is distinct from action ingestion and restricted to review } }); +test('action SLAs are elapsed time on product and buy actions', async () => { + const elapsed = { response_max: 'PT4H', completion_max: 'P1D' }; + for (const name of ['core/product-allowed-action.json', 'core/canonical-product-action.json']) { + const validate = await compile(name); + const value = { action: 'pause', modes: ['seller_managed'] }; + accepts(validate, { ...value, sla: elapsed }); + rejects(validate, { ...value, sla: business }); + rejects(validate, { ...value, sla: { ...elapsed, clock: 'elapsed' } }); + } + for (const name of ['core/media-buy-available-action.json', 'core/canonical-media-buy-action.json']) { + const validate = await compile(name); + const value = { action: 'pause', task: 'control_media_buy', mode: 'seller_managed' }; + accepts(validate, { ...value, sla: elapsed }); + rejects(validate, { ...value, sla: business }); + rejects(validate, { ...value, sla: { ...elapsed, clock: 'elapsed' } }); + } +}); + test('escalation commitments require human response timing and prohibit business clocks', async () => { const validate = await compile('core/escalation-sla.json'); accepts(validate, escalation); - accepts(validate, { clock: 'elapsed', response_max: 'PT15M' }); + assert.equal(read('core/escalation-sla.json').properties.clock, undefined); + rejects(validate, { clock: 'elapsed', response_max: 'PT15M' }); rejects(validate, {}); rejects(validate, { completion_max: 'PT2H' }); rejects(validate, { ...escalation, completion_max: 'PT2H' }); @@ -179,12 +224,20 @@ test('report requests require replay identity, authorized resource context, seve test('review declarations and operational reports do not add negotiated change rights', async () => { const validate = await compile('media-buy/change-term.json'); - const term = { term_id: 'creative_change', action: 'replace_creative', service_mode: 'seller_managed', processing_sla: business }; + const term = { term_id: 'creative_change', action: 'replace_creative', service_mode: 'seller_managed', processing_sla: { completion_max: 'P2D' } }; accepts(validate, term); rejects(validate, { ...term, review_sla: business }); rejects(validate, { ...term, action: 'report_media_buy_issue' }); }); +test('signed change terms keep elapsed-time processing SLAs', async () => { + const validate = await compile('media-buy/change-term.json'); + const term = { term_id: 'pause_right', action: 'pause', service_mode: 'seller_managed', processing_sla: { response_max: 'PT4H', completion_max: 'P1D' } }; + accepts(validate, term); + rejects(validate, { ...term, processing_sla: business }); + rejects(validate, { ...term, processing_sla: { ...term.processing_sla, clock: 'elapsed' } }); +}); + test('polling a report requires account, media buy, and issue identities', async () => { const validate = await compile('media-buy/get-media-buy-issue-request.json'); const value = { account: report.account, media_buy_id: issue.media_buy_id, issue_id: issue.issue_id };