From f591f8718901abba70b1471e250e7797b933dbf7 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 6 Nov 2026 00:05:27 +0000 Subject: [PATCH 1/2] fix(compliance): return the held proposal ID in simulated, not forced (#8501 entry 1) force_hold_unavailable registered its directive on ForcedDirectiveSuccess with a new `unable` arm and a `forced.proposal_id` echo. `forced` is closed, and a minor does not add to a closed schema (DR-0036), so both are withdrawn and ForcedDirectiveSuccess goes back to its 3.2 shape. The scenario now answers on SimulationSuccess with `simulated.proposal_id`, the value the call applied. The hold_unavailable_batch storyboard reads it there, the training agent returns it there, and the additive-minor baseline drops the `forced.proposal_id` entry. Refs #8501 Co-Authored-By: Claude Opus 5.5 --- ...raw-forced-proposal-id-hold-unavailable.md | 9 +++ .../by-layer/L3/comply-test-controller.mdx | 11 +++- docs/reference/experimental-status.mdx | 2 +- scripts/lint-additive-minor-baseline.json | 7 -- server/src/training-agent/holds/service.ts | 5 +- .../unit/training-agent-hold-vectors.test.ts | 3 +- .../scenarios/hold_unavailable_batch.yaml | 6 +- .../comply-test-controller-response.json | 65 ++----------------- tests/hold-policy.test.cjs | 46 ++++++++----- 9 files changed, 60 insertions(+), 94 deletions(-) create mode 100644 .changeset/withdraw-forced-proposal-id-hold-unavailable.md diff --git a/.changeset/withdraw-forced-proposal-id-hold-unavailable.md b/.changeset/withdraw-forced-proposal-id-hold-unavailable.md new file mode 100644 index 00000000000..987752829a9 --- /dev/null +++ b/.changeset/withdraw-forced-proposal-id-hold-unavailable.md @@ -0,0 +1,9 @@ +--- +"adcontextprotocol": minor +--- + +Withdraw `forced.proposal_id` and the `unable` value of `forced.arm` from the `comply_test_controller` response in 3.3 (adcp#8501, entry 1). The experimental `force_hold_unavailable` scenario (`media_buy.hold_policy`) now returns the proposal ID in `simulated.proposal_id` on the `SimulationSuccess` branch, which holds the values a call injected or applied. The `forced` object is closed, and a minor release does not add to a closed schema (DR-0036), so `ForcedDirectiveSuccess` goes back to its 3.2 shape: arms `submitted`, `completed`, `input-required` and `rejected`, and no `proposal_id`. + +**Both values shipped in `3.3.0-beta.0` through `3.3.0-beta.4` and are removed after beta.4.** This is a beta-to-beta change, not a change to a stable release. Those betas are immutable, so a seller or SDK built from one of them that answers `force_hold_unavailable` with `forced: { arm: "unable", proposal_id }` should answer `simulated: { proposal_id }` instead. Nothing else about the scenario changes: its request, its errors, and what the next finalize returns stay the same. + +The `media_buy_seller/hold_unavailable_batch` storyboard reads the proposal ID from `simulated.proposal_id`, and the training agent returns it there. diff --git a/docs/building/by-layer/L3/comply-test-controller.mdx b/docs/building/by-layer/L3/comply-test-controller.mdx index 2e784a120ec..c000bb1c2a4 100644 --- a/docs/building/by-layer/L3/comply-test-controller.mdx +++ b/docs/building/by-layer/L3/comply-test-controller.mdx @@ -944,7 +944,16 @@ Experimental (`media_buy.hold_policy`). Makes the next [`refine_proposals`](/doc } ``` -A successful registration uses the same `ForcedDirectiveSuccess` response shape as the `force_*_arm` scenarios: `success: true`, `forced.arm: "unable"` (the per-entry finalize outcome), and `forced.proposal_id` echoing the draft. Nothing else changes yet, and the draft stays finalizable. +A successful registration uses the `SimulationSuccess` response shape: `success: true` and `simulated.proposal_id` echoing the draft. It carries no `forced` object, because the failure is a per-entry finalize outcome rather than a response arm. Nothing else changes yet, and the draft stays finalizable. + +```json +{ + "success": true, + "simulated": { + "proposal_id": "prop_draft_ctv" + } +} +``` The directive applies to the next finalize from the same sandbox account (account + principal pair) whose batch includes that proposal and that reaches hold placement. For that batch the seller MUST: diff --git a/docs/reference/experimental-status.mdx b/docs/reference/experimental-status.mdx index fe38ebb6706..61684762879 100644 --- a/docs/reference/experimental-status.mdx +++ b/docs/reference/experimental-status.mdx @@ -140,7 +140,7 @@ Schemas marked with `x-status: experimental` are the authoritative source. The c | `media_buy.measurement_provenance` | The `billing_measurement.counting_mechanisms[]` declaration on `core/measurement-terms.json`, the optional `vendor_metric_provenance[]` array (`core/vendor-metric-provenance.json`: `vendor`, `metric_id`, `qualifier?`, `mechanisms[]`, `deduplicated?`) beside `vendor_metric_values` on `core/delivery-metrics.json`, and the `enums/collection-mechanism.json` enum (#6878, #2041; see [Declaring how the billing count is built](/docs/media-buy/advanced-topics/billing-authority#declaring-how-the-billing-count-is-built-experimental) and [Collection-mechanism provenance on vendor values](/docs/media-buy/media-buys/optimization-reporting#collection-mechanism-provenance-on-vendor-values-experimental)) | New cross-party disclosure of what a count is built from, with no production adopter yet. The enum is closed and released values cannot be removed on a stable surface, so the vocabulary (for example whether `client_tracker` and `sdk` stay distinct, or whether a sensor value is needed for place-based media) is held experimental until two parties have used it. The `deduplicated` boolean may become a level enum, and the `impression_id` joinability and `seller_tracker_events[]` follow-ups may reshape the declaration. `measurement_source` on delivery metrics (the provider slug) is stable and unrelated. The closed `canonical-measurement-terms.json` and `vendor-metric-value.json` row do not carry these fields in 3.x (DR-0036): provenance is a sibling array joined on the row key, and `counting_mechanisms` reaches the canonical compact-lifecycle terms in 4.0 ([4.0 compatibility check](/docs/reference/migration/v4-compatibility#canonical_counting_mechanisms_required_in_4_0)). | | `media_buy.lineage` | Forward-only buy-level lineage: the `predecessor_media_buy_id` and `lineage_reason` fields on [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy), [`buy_products`](/docs/media-buy/task-reference/buy_products), and [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal) requests and on [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys) items (also `core/media-buy.json`), the `predecessor_media_buy_id` filter on `get_media_buys`, and the `enums/media-buy-lineage-reason.json` enum (#5849; see [Media Buy Lineage](/docs/media-buy/specification#media-buy-lineage)) | One production sales agent has run equivalent lineage operations for months, and IAB AAMP seller-agent 2.0 (`parent_deal_id`) is prior art for the same idea in deal-scoped form, not convergence evidence. The `lineage_reason` vocabulary is closed and may grow, and the `UNSUPPORTED_FEATURE` rejection and the same-account scope may change as more sellers implement it. | | `media_buy.delegated_composition` | `criteria.product_ids_exclusive` on [`request_proposals`](/docs/media-buy/task-reference/request_proposals#binding-the-product-universe-with-product_ids_exclusive) and proposal refinement, the `media_buy.supports_exclusive_product_ids` capability, and `media_buy.proposal_refinement.hold_semantics` (`terms_only`) with its `committed`, `expires_at`, and `accept_proposal` semantics, described in the [delegated composition profile](/docs/media-buy/product-discovery/delegated-composition) | Zero validated adopters so far, and `terms_only` reinterprets what `committed` means for a capability-gated seller. The acceptance-only `accept_proposal` variant, the governance trigger when the MediaBuy is created on the requester, and a reference-based product universe are still open and may reshape these fields. A `terms_only` seller must not return `committed` proposals to callers below 3.3. | -| `media_buy.hold_policy` | The `hold_policy` object (schema: `core/hold-policy.json`: `available`, `min_duration`, `max_duration`, `extendable`, `max_total_duration`) as a planning hint on products (`core/product.json`, `core/canonical-product.json`) and as the binding policy on canonical proposals (`core/canonical-proposal.json`), the committed proposal's `hold_placed_at`, the per-entry resolution of non-holdable drafts to terms-only finalize, and its role overriding the agent-level `hold_semantics` default, described in [Hold policy](/docs/media-buy/task-reference/refine_proposals#hold-policy-experimental) ([#7661](https://github.com/adcontextprotocol/adcp/issues/7661)) | One named adopter so far: Availnet, a creator marketplace, where each creator's upcoming-video slot can be held while the creator confirms. `rankable` belongs to `media_buy.hold_ranking`, per-installment readback to `media_buy.installment_holds`, and `extendable` and `max_total_duration` bound `extend_hold` (`media_buy.hold_extension`). Reuses the `terms_only` semantics of `media_buy.delegated_composition` without requiring that feature id. Both fields are omitted for callers below 3.3. The `comply_test_controller` scenario `force_hold_unavailable` ([compliance test controller](/docs/building/by-layer/L3/comply-test-controller#force_hold_unavailable)) and its `forced.arm: "unable"` response echo ride on this feature id and carry no feature id of their own. | +| `media_buy.hold_policy` | The `hold_policy` object (schema: `core/hold-policy.json`: `available`, `min_duration`, `max_duration`, `extendable`, `max_total_duration`) as a planning hint on products (`core/product.json`, `core/canonical-product.json`) and as the binding policy on canonical proposals (`core/canonical-proposal.json`), the committed proposal's `hold_placed_at`, the per-entry resolution of non-holdable drafts to terms-only finalize, and its role overriding the agent-level `hold_semantics` default, described in [Hold policy](/docs/media-buy/task-reference/refine_proposals#hold-policy-experimental) ([#7661](https://github.com/adcontextprotocol/adcp/issues/7661)) | One named adopter so far: Availnet, a creator marketplace, where each creator's upcoming-video slot can be held while the creator confirms. `rankable` belongs to `media_buy.hold_ranking`, per-installment readback to `media_buy.installment_holds`, and `extendable` and `max_total_duration` bound `extend_hold` (`media_buy.hold_extension`). Reuses the `terms_only` semantics of `media_buy.delegated_composition` without requiring that feature id. Both fields are omitted for callers below 3.3. The `comply_test_controller` scenario `force_hold_unavailable` ([compliance test controller](/docs/building/by-layer/L3/comply-test-controller#force_hold_unavailable)) and its `simulated.proposal_id` response echo ride on this feature id and carry no feature id of their own. | | `media_buy.hold_ranking` | `hold_policy.rankable` (`core/hold-policy.json`) and the committed proposal's `hold_rank` (`core/canonical-proposal.json`): advisory readback of the buyer's own position in a seller's queue of holds, described in [Hold ranking](/docs/media-buy/task-reference/refine_proposals#hold-ranking-experimental) ([#8086](https://github.com/adcontextprotocol/adcp/issues/8086)) | One named adopter so far: Availnet, a creator marketplace. Ranking is advisory only: no right-of-first-refusal deadline, no rank-change webhook, and no read of a committed proposal, so `hold_rank` is a snapshot that a buyer refreshes through an `extend_hold` successor. Whether buyers need a fresher readback, and whether sellers will report exact queue depth, needs field experience. `available: true` without `rankable: true` keeps its exclusive, first-position meaning. Requires `media_buy.hold_policy`; omitted for callers below 3.3, for whom the seller treats `rankable` as `false`. | | `media_buy.collection_catalog` | [`list_collections`](/docs/media-buy/task-reference/list_collections) (schemas: `media-buy/list-collections-request.json`, `media-buy/list-collections-response.json`), its `collections_version` / `if_collections_version` tokens, and the `collection_card.updated`, `collection_card.removed` and `collection_catalog.bulk_change` webhook event types (branches of `core/wholesale-feed-event.json`, delivered in `core/wholesale-feed-webhook.json` and listed in `wholesale_feed_webhooks.event_types`) ([#8183](https://github.com/adcontextprotocol/adcp/issues/8183)) | Standalone catalog read and push channel for collection cards, with one adopter behind the card surface so far (Availnet, a creator marketplace) and no emitting agent yet. Requires `media_buy.collection_cards`, because the response carries card fields; a seller can serve cards in-band without it. Webhooks are optional within the feature and declared per type. The filter set, the reuse of the wholesale feed envelope with `collections_version` in `wholesale_feed_version`, and the bulk threshold for follower sweeps may change. Not served to callers below 3.3. Storyboards and a `seed_collection` controller scenario wait for training-agent support ([#8181](https://github.com/adcontextprotocol/adcp/issues/8181)). | | `media_buy.installment_holds` | The committed proposal's `holds` array (`core/canonical-proposal.json`): `product_id`, `installment_ref`, and, under a rankable policy, `hold_rank` for each installment the hold covers, described in [Installment holds](/docs/media-buy/task-reference/refine_proposals#installment-holds-experimental) ([#8086](https://github.com/adcontextprotocol/adcp/issues/8086)) | One named adopter so far: Availnet, a creator marketplace, where the held unit is a creator's specific upcoming video. Readback only: entries describe one all-or-none hold and share the proposal's `expires_at`, so there are no partial holds. Whether entries need their own `expires_at`, which only makes sense if partial holds are ever allowed, needs field experience. Requires `media_buy.hold_policy`; omitted for callers below 3.3. | diff --git a/scripts/lint-additive-minor-baseline.json b/scripts/lint-additive-minor-baseline.json index 64c568af27a..0b2e0496dcf 100644 --- a/scripts/lint-additive-minor-baseline.json +++ b/scripts/lint-additive-minor-baseline.json @@ -2,13 +2,6 @@ "$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, unless its change already shipped in a stable release. Remove an entry when its change is reverted or released; record a WG decision in reason. An entry whose detail ends in \"(closed in )\" records an addition to a schema that an earlier release of the major published closed (DR-0036). It shipped before the base and stays until the next major.", "base": "3.2.8", "entries": [ - { - "file": "compliance/comply-test-controller-response.json", - "pointer": "/oneOf/3/properties/forced", - "kind": "property-added-to-closed", - "detail": "property proposal_id added", - "reason": "pending WG decision" - }, { "file": "core/agent-notification-config.json", "pointer": "", diff --git a/server/src/training-agent/holds/service.ts b/server/src/training-agent/holds/service.ts index aa394533617..fee1c294717 100644 --- a/server/src/training-agent/holds/service.ts +++ b/server/src/training-agent/holds/service.ts @@ -939,9 +939,12 @@ async function registerForcedHoldUnavailable(params: Json, ctx: TrainingContext) saveHoldDocument(session, doc, await sandboxNow(ctx)); await flushDirtySessions(); } + // The registration answers on the SimulationSuccess branch: `simulated` + // carries the value this call applied. ForcedDirectiveSuccess (`forced.arm`) + // has no arm for a per-entry finalize outcome (adcp#8501). return { success: true, - forced: { arm: 'unable', proposal_id: proposalId }, + simulated: { proposal_id: proposalId }, message: `The next finalize that includes ${proposalId} returns hold_unavailable`, }; } diff --git a/server/tests/unit/training-agent-hold-vectors.test.ts b/server/tests/unit/training-agent-hold-vectors.test.ts index 6dea57f1698..df2c46abd20 100644 --- a/server/tests/unit/training-agent-hold-vectors.test.ts +++ b/server/tests/unit/training-agent-hold-vectors.test.ts @@ -299,7 +299,8 @@ describe('experimental 3.3 holds graded by the reference hold-policy grader', () scenario: 'force_hold_unavailable', params: { proposal_id: forced.draft.proposal_id }, }); - expect(directive.structured, JSON.stringify(directive.structured)).toMatchObject({ success: true, forced: { arm: 'unable', proposal_id: forced.draft.proposal_id } }); + expect(directive.structured, JSON.stringify(directive.structured)).toMatchObject({ success: true, simulated: { proposal_id: forced.draft.proposal_id } }); + expect(directive.structured).not.toHaveProperty('forced'); const drafts = [forced.draft, sibling.draft, termsOnly.draft]; const failed = await finalize(drafts, { label: 'forced batch' }); expect(failed.structured.results.map((entry: Json) => entry.reason_code)).toEqual(['hold_unavailable', 'batch_aborted', 'batch_aborted']); diff --git a/static/compliance/source/protocols/media-buy/scenarios/hold_unavailable_batch.yaml b/static/compliance/source/protocols/media-buy/scenarios/hold_unavailable_batch.yaml index 2fe9cc127ea..234f0e5fe75 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/hold_unavailable_batch.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/hold_unavailable_batch.yaml @@ -240,12 +240,8 @@ phases: path: "success" value: true description: "The directive is registered" - - check: field_value - path: "forced.arm" - value: "unable" - description: "The directive registers the unable arm" - check: field_equals_context - path: "forced.proposal_id" + path: "simulated.proposal_id" context_key: "second_draft_id" description: "The directive names the second draft" diff --git a/static/schemas/source/compliance/comply-test-controller-response.json b/static/schemas/source/compliance/comply-test-controller-response.json index 8954845b559..6b8a9e91390 100644 --- a/static/schemas/source/compliance/comply-test-controller-response.json +++ b/static/schemas/source/compliance/comply-test-controller-response.json @@ -194,7 +194,7 @@ }, { "title": "SimulationSuccess", - "description": "A simulate_delivery, simulate_budget_spend, simulate_later_proposal, catalog_item_availability_probe, compact_product_lifecycle_probe, compact_direct_buy_lifecycle_probe, reporting_core_lifecycle_probe, reporting_subscription_probe, or Reliable Reporting tier-probe operation succeeded. For delivery: simulated contains the metrics injected by this call (impressions/clicks/plays/reported_spend/conversions plus optional DOOH, reach, frequency, reach-window, and viewability values) and cumulative contains running totals or latest non-additive metric state. For budget: simulated contains spend_percentage/computed_spend/budget. For a later proposal: simulated contains proposal_id, opportunity_id, and later_reason of the created snapshot. For catalog availability: simulated reports seeded foreign identity, actual eligibility gates, processed expiry time, or delete/recreate generation rotation according to params.operation. For compact product lifecycle: simulated reports deterministic preparation through MediaBuy control/readback or strict post-deadline proposal expiry. For compact direct-buy lifecycle: simulated reports deterministic preparation. For reporting Core: simulated reports the fixed virtual time plus stable account, configuration, obligation, and revision identifiers created or advanced by the operation; prepare also reports the complete resolved configuration, period, expected_at, and recovery_deadline, restate_snapshot reports the new revision plus supersedes_reporting_revision_id, and restate_after_received additionally reports received_reporting_revision_id, restated_at, and stale_received_grace_deadline; advance_past_status_deadline reports expected_at, automated_recovery_window_seconds, and the crossed consumer_status_deadline; and advance_past_escalation reports issue_id, issue_opened_at, consumer_mismatch_escalation_seconds, consumer_mismatch_escalation_deadline, expected_recommended_action, and any still-future stale_received_grace_deadline. For reporting subscriptions: simulated reports simulated_now and account_id; revoke_grant adds unauthorized_at; regrant adds authorized_at, regranted_at when the re-grant continued the chain, and chain (continued or new); reduce_grant adds the applied allowed_tasks; set_account_timezone returns SetAccountTimezoneResult (previous_timezone, timezone, changed_at, and the per-offering timezone_cutovers); advance_time adds the applied simulated_now. Reliable Reporting tier probes return the exact seeded ledger IDs, checkpoints, notification order, resource lifecycle evidence, and receipt bodies required by their storyboards.", + "description": "A simulate_delivery, simulate_budget_spend, simulate_later_proposal, force_hold_unavailable, catalog_item_availability_probe, compact_product_lifecycle_probe, compact_direct_buy_lifecycle_probe, reporting_core_lifecycle_probe, reporting_subscription_probe, or Reliable Reporting tier-probe operation succeeded. For delivery: simulated contains the metrics injected by this call (impressions/clicks/plays/reported_spend/conversions plus optional DOOH, reach, frequency, reach-window, and viewability values) and cumulative contains running totals or latest non-additive metric state. For budget: simulated contains spend_percentage/computed_spend/budget. For a later proposal: simulated contains proposal_id, opportunity_id, and later_reason of the created snapshot. For force_hold_unavailable (experimental, media_buy.hold_policy): simulated contains proposal_id, the draft whose next refine_proposals finalize will fail with hold_unavailable. Registering the directive changes nothing else, and the draft stays a draft. For catalog availability: simulated reports seeded foreign identity, actual eligibility gates, processed expiry time, or delete/recreate generation rotation according to params.operation. For compact product lifecycle: simulated reports deterministic preparation through MediaBuy control/readback or strict post-deadline proposal expiry. For compact direct-buy lifecycle: simulated reports deterministic preparation. For reporting Core: simulated reports the fixed virtual time plus stable account, configuration, obligation, and revision identifiers created or advanced by the operation; prepare also reports the complete resolved configuration, period, expected_at, and recovery_deadline, restate_snapshot reports the new revision plus supersedes_reporting_revision_id, and restate_after_received additionally reports received_reporting_revision_id, restated_at, and stale_received_grace_deadline; advance_past_status_deadline reports expected_at, automated_recovery_window_seconds, and the crossed consumer_status_deadline; and advance_past_escalation reports issue_id, issue_opened_at, consumer_mismatch_escalation_seconds, consumer_mismatch_escalation_deadline, expected_recommended_action, and any still-future stale_received_grace_deadline. For reporting subscriptions: simulated reports simulated_now and account_id; revoke_grant adds unauthorized_at; regrant adds authorized_at, regranted_at when the re-grant continued the chain, and chain (continued or new); reduce_grant adds the applied allowed_tasks; set_account_timezone returns SetAccountTimezoneResult (previous_timezone, timezone, changed_at, and the per-offering timezone_cutovers); advance_time adds the applied simulated_now. Reliable Reporting tier probes return the exact seeded ledger IDs, checkpoints, notification order, resource lifecycle evidence, and receipt bodies required by their storyboards.", "type": "object", "properties": { "success": { @@ -271,7 +271,7 @@ }, { "title": "ForcedDirectiveSuccess", - "description": "A forced response-arm directive was registered. The directive shapes the next matching operation call from this caller's authenticated sandbox account into the requested arm, then is consumed. No entity transitioned yet, so this branch carries 'forced' rather than previous_state/current_state. The experimental force_hold_unavailable scenario (media_buy.hold_policy) registers the 'unable' arm for one draft proposal, echoed in forced.proposal_id: the next refine_proposals finalize that includes it fails with hold_unavailable, siblings with batch_aborted, and the directive is then consumed.", + "description": "A forced response-arm directive was registered. The directive shapes the next matching operation call from this caller's authenticated sandbox account into the requested arm, then is consumed. No entity transitioned yet, so this branch carries 'forced' rather than previous_state/current_state.", "type": "object", "properties": { "success": { @@ -288,10 +288,9 @@ "submitted", "completed", "input-required", - "rejected", - "unable" + "rejected" ], - "description": "Arm the seller will emit on the next forced operation response. 'unable' (experimental, media_buy.hold_policy; added in 3.3) is the per-entry refine_proposals finalize outcome registered by force_hold_unavailable: the named draft returns unable with reason_code hold_unavailable, its otherwise-eligible batch siblings return unable with reason_code batch_aborted, no committed proposal is created and no hold is placed." + "description": "Arm the seller will emit on the next forced operation response." }, "task_id": { "description": "Echo of the registered task_id. Present only when arm is 'submitted' (the arm that emits a task envelope).", @@ -321,14 +320,6 @@ "minLength": 1, "maxLength": 1000 } - }, - "proposal_id": { - "type": "string", - "minLength": 1, - "description": "Echo of the draft proposal whose next finalize will fail with hold_unavailable. Present only when arm is 'unable'.", - "x-entity": "proposal", - "x-status": "experimental", - "x-added-in": "3.3.0" } }, "required": [ @@ -379,51 +370,6 @@ ] } } - }, - { - "if": { - "properties": { - "arm": { - "const": "unable" - } - } - }, - "then": { - "required": [ - "proposal_id" - ], - "not": { - "anyOf": [ - { - "required": [ - "task_id" - ] - }, - { - "required": [ - "evaluation_id" - ] - }, - { - "required": [ - "reason" - ] - }, - { - "required": [ - "suggestions" - ] - } - ] - } - }, - "else": { - "not": { - "required": [ - "proposal_id" - ] - } - } } ] }, @@ -1113,8 +1059,7 @@ "data": { "status": "completed", "success": true, - "forced": { - "arm": "unable", + "simulated": { "proposal_id": "prop_draft_ctv" }, "message": "The next finalize of prop_draft_ctv will fail with hold_unavailable" diff --git a/tests/hold-policy.test.cjs b/tests/hold-policy.test.cjs index d9041009692..131a2a1651f 100644 --- a/tests/hold-policy.test.cjs +++ b/tests/hold-policy.test.cjs @@ -252,30 +252,40 @@ test('comply_test_controller force_hold_unavailable requires the draft proposal_ assert.equal(validate(request({ params: { proposal_id: '' } })), false, 'proposal_id is not empty'); }); -test('comply_test_controller reports a registered hold failure as the unable arm with the forced proposal_id', async () => { +test('comply_test_controller reports a registered hold failure in simulated, not as a forced arm', async () => { const validate = await compile('/schemas/compliance/comply-test-controller-response.json'); - const forced = { + const registered = { adcp_version: '3.3', status: 'completed', success: true, - forced: { arm: 'unable', proposal_id: 'availnet_draft_01' }, + simulated: { proposal_id: 'availnet_draft_01' }, }; - assert.equal(validate(forced), true, errors(validate)); - assert.equal(validate({ ...forced, message: 'registered' }), true, errors(validate)); - assert.equal(validate({ ...forced, forced: { arm: 'unable' } }), false, 'proposal_id is required for the unable arm'); - assert.equal(validate({ ...forced, forced: { proposal_id: 'availnet_draft_01' } }), false, 'arm is required'); - assert.equal(validate({ ...forced, forced: { arm: 'unable', proposal_id: 'p', task_id: 't' } }), false, 'no task_id on unable'); - assert.equal(validate({ ...forced, forced: { arm: 'submitted', proposal_id: 'p', task_id: 't' } }), false, 'proposal_id is only for the unable arm'); - assert.equal(validate({ ...forced, forced: { arm: 'submitted', task_id: 't' } }), true, 'the existing arm directives are unchanged'); - assert.equal(validate({ ...forced, previous_state: 'draft' }), false, 'not a state transition'); - - const directive = readSchema('compliance/comply-test-controller-response.json').oneOf.find( - branch => branch.title === 'ForcedDirectiveSuccess', + assert.equal(validate(registered), true, errors(validate)); + assert.equal(validate({ ...registered, message: 'registered' }), true, errors(validate)); + assert.equal( + validate({ ...registered, forced: { arm: 'submitted', task_id: 't' } }), + false, + 'a simulation result carries no forced directive', + ); + assert.equal(validate({ ...registered, previous_state: 'draft' }), false, 'not a state transition'); + + // forced.proposal_id and the 'unable' arm were withdrawn from 3.3 (adcp#8501): + // ForcedDirectiveSuccess is closed and stays in its 3.2 shape. + const withdrawn = { adcp_version: '3.3', status: 'completed', success: true }; + assert.equal(validate({ ...withdrawn, forced: { arm: 'unable' } }), false, 'unable is not a forced arm'); + assert.equal( + validate({ ...withdrawn, forced: { arm: 'submitted', task_id: 't', proposal_id: 'p' } }), + false, + 'forced carries no proposal_id', ); - const proposalId = directive.properties.forced.properties.proposal_id; - assert.equal(proposalId['x-status'], 'experimental'); - assert.equal(proposalId['x-added-in'], '3.3.0'); - assert.match(directive.properties.forced.properties.arm.description, /force_hold_unavailable/); + assert.equal(validate({ ...withdrawn, forced: { arm: 'submitted', task_id: 't' } }), true, 'the existing arm directives are unchanged'); + + const branches = readSchema('compliance/comply-test-controller-response.json').oneOf; + const directive = branches.find(branch => branch.title === 'ForcedDirectiveSuccess'); + assert.equal(directive.properties.forced.properties.proposal_id, undefined); + assert.deepEqual(directive.properties.forced.properties.arm.enum, ['submitted', 'completed', 'input-required', 'rejected']); + const simulation = branches.find(branch => branch.title === 'SimulationSuccess'); + assert.match(simulation.description, /force_hold_unavailable/); }); test('rankable is a hold-only boolean that defaults to false', async () => { From 9e827a8d8226d6d5bc80832733658706e8b1d89d Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 6 Nov 2026 11:00:17 +0000 Subject: [PATCH 2/2] docs(compliance): note the force_hold_unavailable response move in the 3.3 guides Beta conformance-runner authors read the migration guide and the beta page, not the changeset. Both now show the old and new shapes: `forced: { arm: "unable", proposal_id }` in 3.3.0-beta.0 through beta.4, `simulated: { proposal_id }` after. Refs #8501 Co-Authored-By: Claude Opus 5.5 --- docs/reference/3-3-beta.mdx | 2 ++ docs/reference/migration/3-2-to-3-3.mdx | 1 + 2 files changed, 3 insertions(+) diff --git a/docs/reference/3-3-beta.mdx b/docs/reference/3-3-beta.mdx index 01c79759750..369711f2de3 100644 --- a/docs/reference/3-3-beta.mdx +++ b/docs/reference/3-3-beta.mdx @@ -172,6 +172,8 @@ Three features shipped in `3.3.0-beta.1` and are not part of 3.3. They leave the The [migration guide](/docs/reference/migration/3-2-to-3-3#withdrawn-from-3-3-planned-for-4-0) lists every surface removed, and [Planned for 4.0](/docs/reference/migration/v4-compatibility#planned-for-4-0-withdrawn-from-3-3) keeps the designs. +One conformance-runner shape also changes after beta.4, for the same closed-schema reason. The experimental [`force_hold_unavailable`](/docs/building/by-layer/L3/comply-test-controller#force_hold_unavailable) controller scenario answered `forced: { arm: "unable", proposal_id }` in `3.3.0-beta.0` through `3.3.0-beta.4`, and now answers `simulated: { proposal_id }`. + ## Required integration evidence Retain the exact protocol bundle, SDK package versions, capability responses, redacted requests and responses, task IDs, and account and MediaBuy identifiers for each run. diff --git a/docs/reference/migration/3-2-to-3-3.mdx b/docs/reference/migration/3-2-to-3-3.mdx index 787978dfc54..31bd78ea743 100644 --- a/docs/reference/migration/3-2-to-3-3.mdx +++ b/docs/reference/migration/3-2-to-3-3.mdx @@ -390,6 +390,7 @@ What this means for 3.3 adopters: - **Features that needed to add terms to `commercial_terms` are 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[]`. - **New 3.3 additions sit on open schemas or in sibling fields.** `target_audiences[]` is a sibling of the closed `audience_evidence_requirements`, `vendor_metric_provenance[]` is a sibling of the closed `vendor_metric_values[]` row, and `counting_mechanisms` reaches the closed canonical measurement terms in 4.0. - **Send a 3.3 field on a reopened schema only to a 3.3 peer.** A strict validator on an earlier 3.2 patch still rejects it ([Send only to 3.3 peers](#send-only-to-3-3-peers)). +- **[`force_hold_unavailable`](/docs/building/by-layer/L3/comply-test-controller#force_hold_unavailable) answers in `simulated`.** In `3.3.0-beta.0` through `3.3.0-beta.4`, this experimental controller scenario answered `forced: { arm: "unable", proposal_id }`. It now answers `simulated: { proposal_id }`, and `forced` keeps its 3.2 arms. ### Capabilities are bounded by structure